* chore: remove legacy orchestrator, trading and one-off script leftovers The repository still carried the pre-evOo prototype: a Python orchestrator, the old HTML "evoo-platform" cockpit (whose test ran in the root unit suite), an apex-trading package, the hot-reload deploy.sh for it, a permissions manifest, an architecture note with local file links, agent handoff notes, a Steve Jobs persona template and about ninety one-off scripts that probe a developer database with hard-coded local URLs. None of it is used by the product, CI or the real deploy in ops/evo-deploy. Delete those files, drop the evoo-platform test glob from the root vitest config, the Cockpit section from .env.example and the dead biome and gitignore entries, and update the docs that listed the removed tools. scripts/start-tmux.sh no longer starts the removed sentinel and resolves the checkout from its own location instead of a machine-specific path; scripts/seed-ollama.ts requires DATABASE_URL instead of falling back to a local URL with a username. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * fix(web): drop trading and persona prompts from the conversation shell The web shell still showed leftovers from the trading prototype: composer chips with German trading and "Steve Jobs" prompts, trading suggestion chips under the empty conversation line, a German trading hero for the empty transcript, and a German "data protection" status panel in the sidebar. They suggested actions the product does not have and added persistent chrome. Remove the composer chips and their helper, the empty-state chips and the hero (the empty conversation keeps its existing one-line start hint), the sidebar trading indicator and the data trust panel. The new-bot E2E now asserts the plain empty state; the sidebar spec no longer expects the trading status, and the placeholder Steve Jobs spec with no assertions is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * refactor: remove the trading bot status integration With the sidebar trading indicator gone, the only remaining consumer of the trading bot status was a banner in General settings that reused the same component. evOo is a personal assistant; a status proxy and poller for one external trading service, configured through service-specific TRADING_BOT_URL and VITE_TRADING_BOT_URL variables, does not belong in the core product. Remove the settings banner and its prop plumbing, the web poller, the indicator component, the /api/trading-bot/status proxy, the shared core contract, the env loading and both variables from .env.example, together with their tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * chore: remove one-off database scripts and a stale review note packages/db carried four hand-run scripts outside the package build that query or seed a developer database with a hard-coded connection string, one of them for a specific account. docs/VOICE-FIRST-REVIEW.md was a one-time verification note for a workflow that no longer exists and named private machines and local paths. Nothing references them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(memory): remember appends dated notes instead of replacing memory The remember tool rewrote the bot's whole MEMORY.md with each call, so every new fact erased the earlier ones unless the model re-sent the full document. - MemoryCommitRequest gains mode "append" (the store joins the note onto the current content inside the same serializable transaction and still writes a revision row) and every commit is capped at 16 KB per document. - remember appends a dated bullet ("- YYYY-MM-DD: ...", the user's local date) by default and rewrites only with mode "replace"; a full document returns an error that tells the bot to condense it. Retries of the same tool call stay single appends through the existing effect record. - Scripted runtime memory entries can append too; the scripted "remember" script now appends instead of overwriting the bot's memory. - When memory does not fit the run's 32 KB budget, the prompt keeps a document's newest lines instead of its oldest. - memory.update maps an oversized save to BAD_REQUEST. - docs/PRODUCT.md records that UserFact is the cross-space user model and the space MEMORY.md stays space notes (no migration). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(coach): goals, habits, check-ins, daily briefs and check-in rhythm The main agent should coach the user, but nothing stored what the user wants to achieve or keep doing, and no routine ever checked in. This adds the data model and the interfaces the onboarding intake and the coach tools build on. - Schema: UserGoal, Habit, CheckIn (one per habit, goal or day and date) and DailyBrief, all keyed to the user's local calendar date, plus a coachKey on Routine that marks the main bot's coach check-ins. - Contracts (coach.ts): CoachRhythm, Goal, Habit, CheckIn, DailyBrief and CoachToday DTOs and the RPC inputs. - Repository (@rakazo/db coach.ts): goal, habit and check-in CRUD with per-user limits, upsertDailyBrief and loadCoachToday with streaks in the user's time zone. - coach-rhythm.ts: setCoachRhythm/getCoachRhythm keep up to three main bot routines (morning brief, evening check-in, weekly review) in the main conversation in step with the user's chosen times. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(notifications): add push subscriptions, notification settings and web push interfaces Push could only reach one mobile device per user (a token file), quiet hours did not exist, and the web app had no service worker, so a closed tab never heard about finished work. This lands the shared interfaces first so onboarding can build on them: - push_subscriptions (web push endpoints and Expo tokens, one row per device) and user_notification_settings (quiet hours as minutes in the profile time zone, nudges, a per-day nudge counter), with CHECK constraints. - NotificationSettingsSchema and web push contracts, notifications.settings.get/update and notifications.webPush.config/subscribe/unsubscribe (owner-scoped, https endpoints only). - getNotificationSettings/updateNotificationSettings plus the policy and nudge-cap helpers. - VAPID keys from VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY or generated once and stored encrypted, and an SSRF-guarded web push sender built on the web-push package. - The web app links its manifest, registers a push-only service worker where supported (secure context, not the desktop app) and exposes webPushSupported/webPushEnabled/enableWebPush/ disableWebPush. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * chore: replace machine-specific paths and addresses with placeholders ORIGIN.md pinned the intended checkout to an absolute path under a personal home directory, docs/LOCAL-SETUP.md listed folders on one developer's machine, and two tests used what looks like a real tailnet address. Use a home-relative path, drop the machine-specific folder list, and use 100.100.100.100 in the tests; it is still inside 100.64.0.0/10, so both tests check the same thing as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test(coach): repository, streak and rhythm suites Covers what the coach interfaces promise before anything builds on them: goal and habit ownership and limits, one check-in per habit, goal and day dated in the user's time zone, streaks across weekends and weeks, and a rhythm that keeps exactly one main-bot routine per check-in and is idempotent when set again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * refactor(contracts): share one clock-time schema The coach rhythm and the quiet hours both parse "HH:MM" wall-clock times and each defined its own ClockTimeSchema, which the contracts index cannot export twice. Both now import it from one module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(onboarding): ask the onboarding questions in the main conversation The wizard asked for the account name again and a "Tell us about yourself" form, and the focus answer in the main conversation was never saved. The main bot now gets to know the user in the main conversation instead, one card after each answer. - UserOnboarding keeps the pending step, the focus and when onboarding finished. - onboarding-intake.ts declares the steps (focus, goal, rhythm, quiet hours, notifications) as cards plus what their answers change; onboarding.ts commits an answer, the focus app suggestions and the next card or the closing line in one transaction. - The choice block gains a step, an optional text input and the typed answer; choose takes the step, the text and whether the device can still turn push on. - The focus answer becomes a goals fact; the goal, rhythm and quiet-hours effects and the push seam are placeholders until the coach and notification modules are merged. - The wizard drops the preferences step and fills time zone, locale and language from the browser; the main bot's prompt notes open onboarding cards; agent history shows a card's question and answer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(notifications): fan push out to every device, honour quiet hours, add notify_user Run notifications reached at most one phone and nothing could hold them back at night. - PushNotificationProvider sends each notification to all of the user's registered devices (web push and Expo), skips everything during quiet hours in the profile time zone, drops devices the push service reports gone (404/410, DeviceNotRegistered), records lastUsedAt and links each push to its conversation. - Expo tokens move from DATA_DIR/push-tokens files into push_subscriptions: the API imports and deletes the legacy files at start; notifications.registerPush/unregisterPush keep their contract for the mobile app (tokens must look like Expo tokens). - NotificationMessage gains the nudge kind, and the main bot gets notify_user: a short push to the main conversation that respects the nudge switch, quiet hours and a cap of three per day. It is withheld from shared messaging channels and from runs answering another owner's agent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(coach): main-bot coach tools and goals in its prompt The main agent had no way to record what the user wants to achieve or how their day went, and its prompt never showed their goals or habits. - update_goal, update_habit, log_check_in and write_daily_brief, main-bot only and, like update_user_profile, withheld from shared channels and from runs that answer another owner's agent. Goals and habits can be named by id or exact title. - The main bot's own runs list active goals and habits (with ids, done state and streak) in the <user> block; the basics-only block and every other bot never see them. The main agent role adds a short coaching stance and names each coach tool only when the run has it, so the check-in routine prompts stay tool-agnostic. - The scripted runtime understands a few coach commands and the morning check-in, so tests can drive the tools end to end. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * fix(web): use English Lingui sources for German UI text Several web surfaces hard-coded German (the project card, roster and activity status labels, the sidebar "Heute" label) or used German text as the Lingui source message (sidebar sections, project timeline, bot picker, voice entry, group stage, copy and read-aloud buttons). English users saw German, and because production builds keep only message ids, none of these strings could be translated per locale. Convert them to English source messages, keeping the meaning: - ProjectCreatedCard: Lingui Trans/Plural instead of literals. - agent-activity: labels go through i18n._ with literal ids so the module stays importable from plain unit tests; the extractor still collects them. - Shell, ProjectSessionTimeline, bot-picker, StageVoiceEntry, OoStage: English sources, reusing existing messages where one already fit ("just now", "{n}m ago", "Search", "No bots match", "Set up voice", "Group conversation", "Transcript"). - The conversation picker matches its create entry against the translated label instead of a fixed German string. The German wording returns through the de catalog in the next commit. Unit and E2E tests now assert the English labels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(web): keep Lingui catalogs complete and in sync The committed catalogs were last extracted weeks ago: about 330 source messages were missing from every locale, and production builds keep only message ids, so those strings rendered as hash ids (sidebar sections, privacy settings, profile, timeline, ...). German also had 226 empty entries, and every extraction rewrote the files because references carried line numbers. - lingui.config.ts: po references without line numbers, and a stable sort that breaks Lingui's ordering ties (explicit ids, equal text) by id, so the output no longer depends on file-system order. - Re-extract all catalogs with --clean (drops 25-44 obsolete entries per locale; no existing translation changed). - Translate every German entry (du form, consistent with the existing catalog). Other locales keep empty entries and fall back to English. - i18n-catalog-sync.test.ts extracts into a copy of the catalogs and fails when the committed files differ or a German entry is empty, so stale catalogs fail the unit test job (offline, about 5 s). Adds @lingui/format-po (already installed through @lingui/cli) as a direct dev dependency to configure the formatter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * refactor(memory): derive the run's memory budget from the document limit The prompt budget was a separate 32 KB constant; it is now twice the per-document limit, so a full bot document and a full space document keep fitting if either number changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(profile): edit facts in place in About you About you could only confirm or delete what the bots know about the user, so a fact that was slightly wrong or filed under the wrong category had to be deleted and taught again, and a fact could not be limited to some spaces from the app even though the profile supports it. - Each fact gets an Edit button that turns its row into an inline editor: wording, category, and (when the user has more than one space, or the fact is already limited) which spaces it shows in. Escape or Cancel leaves it unchanged; only changed fields are sent through profile.update editFacts. - A limited fact names its spaces next to its source. - The repository validates edits like new facts: a wording whose normalized form is empty or equals another fact of the user is refused, all edits of one update apply atomically, and the stored embedding is only dropped when the wording really changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(inbox): list and answer items from all of the user's spaces The inbox in the main conversation only showed asks, approvals, takeovers and unread threads of the current space. The main conversation lives in the home space, so anything a bot needed in a project space never reached it. - inbox.list now covers every space the user belongs to (except spaces being deleted), still only the user's own bots, runs and threads. Items from another space carry that space's id and name. - The web inbox shows the space name next to the bot for those items, answers them with that space's header so the existing thread procedures check membership and ownership for that space, and opens their thread in it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(coach): coach RPCs for goals, habits, check-ins, today and rhythm The web app needs to show and change the user's coach data, and the check-in times need a way to change after onboarding. - coach.today, coach.goals.*, coach.habits.*, coach.checkIns.* and coach.rhythm.get/set, owner-only and not space-scoped; another user's ids read as not found, invalid input as a bad request. - coach-rhythm reuses the shared clock-time helpers. - Tests: an API suite covering ownership and validation for every procedure, the coach tools driven through the main bot (and refused for a specialist), and a morning check-in routine that fires in the main conversation and writes today's brief; the unauthenticated authorization sweep lists the new procedures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(onboarding): save goal, check-in rhythm, quiet hours and push from the cards With the coach and notification modules merged, the onboarding answers now take effect: - the goal card creates a goal (source onboarding) through createGoal; - the rhythm card sets the main bot's check-in routines through setCoachRhythm, and the closing line names the first check-in from the routines that were saved; - the quiet-hours card updates the notification settings; - the choice card tells the server whether this browser can still turn push on, so the notifications card is offered only there, and "Turn on" subscribes before answering. The thread lock becomes NO KEY UPDATE so a rhythm routine, which references the thread, can be written while the answer holds the lock. The goal answer is capped at the coach title length. A Postgres suite walks the questions end to end: answers, skips, a dismissed card, a stale and a doubled answer, another bot's focus card, and another user's access. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test(notifications): cover quiet hours, delivery, nudges and the notification RPCs The quiet-hours CHECK accepted a row with only one bound set, because a NULL comparison made the whole condition NULL; it now requires both bounds explicitly. The migration has not shipped yet, so it is corrected in place. - Unit: quiet-hours math across midnight, time zones and daylight saving; VAPID key handling (configured, generated once and shared, invalid); the web push sender (encrypted VAPID-signed request, gone on 404/410, no private or non-https endpoints); the fan-out with fake senders. - PostgreSQL: settings and subscription repositories (constraints, per-user removal, endpoint hand-over, device cap, concurrent nudge cap, cascade on account deletion), delivery and notify_user against a real main conversation, and every notification RPC through the API including cross-user isolation and endpoint validation. - .env.example documents the optional VAPID keys and VAPID_SUBJECT. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(web): notification settings, and sign-out stops this browser's push Users had no way to turn on push for a browser, set quiet hours or opt out of nudges. Settings gains a compact Notifications section: a switch for push on this device (only where web push works: a secure context outside the desktop app), quiet hours with from/to times (22:00-07:00 when first turned on) and a switch for nudges. Changes save in order as they are made. A browser that blocked notifications says so instead of offering a dead switch. Logging out unregisters the browser first, so a shared computer stops receiving the account's pushes. The E2E stubs the Push API (headless Chromium has no push service), walks all three controls, checks they survive a reload and captures the notification-settings screenshot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * fix(profile): keep the fact editor's Save label visible Small buttons in ui-web merge their text-ui-label size over the default variant's text color, so the small Save button rendered ink on ink. The editor now uses default-size controls, with the category select at the same height. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * refactor(notifications): keep the push sender contract next to the fan-out The payload, target and outcome types are shared by the Expo and web push senders, so they now live with PushSender in push-notifications.ts instead of in the web push adapter. The settings E2E also stubs the Notification permission (headless Chromium reports it as denied even when granted), and the quiet-hours inputs are wide enough for 12-hour times. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * fix(core): load child_process normally in the git checkout reader readGitCheckout loaded node:child_process through require(), which does not exist in the ESM API runtime. Linking a project space to a folder that is a git checkout therefore failed after the space was created. The lazy load is no longer needed: the reader is only reachable through the server-only "@rakazo/core/git-checkout" subpath, never through the package entry the web app bundles. Import spawn at the top instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(onboarding): ask before discovering projects and let the user choose The onboarding scan started on its own, discarded what it found, claimed to read email and calendars, simulated progress for everyone who could not scan, and its project creation always failed (wrong Space fields and a space lookup by title across tenants). Discovery now asks first and creates nothing without a choice: - The scan step appears only for the deployment owner when ONBOARDING_SCAN_HOME is set; everyone else skips it. The simulated scan is gone. - It shows the folder and asks. Only directory listings are read: git projects up to four levels deep, their languages and a few tools (Docker, GitHub Actions, Terraform). Symlinks are never followed and each chosen folder is resolved again before use, so nothing outside the folder is reached. Results stay with the account that scanned. - Found projects are a checklist (none preselected); found languages and tools are facts (preselected). "Add" creates one project space per chosen project with createSpaceForMember and wireProjectWorkspace in the user's own organization, reusing the user's own space already linked to that folder, and saves the chosen facts as onboarding facts under the profile lock and fact limit. - When a connection becomes connected (direct connect, authorization round trip, or catalog reconciliation), "Uses <App>" is remembered as a tools fact from the connector. Revoking keeps it. The E2E harness now points ONBOARDING_SCAN_HOME at a fixture folder and starts without a deployment owner, so owner-only onboarding steps appear only in specs that take the seat through /__e2e/deployment-owner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(web): coach on the Heute page Heute only listed runs and linked to a hard-coded local Mission Control address, so the coach's work had nowhere to show. - Today's brief at the top, habits with a check-off and their streak, and active goals, each only when there is something to show; then the existing needs-you, doing and done lists. - One icon button opens the check-in times (morning, evening, weekly), loaded when opened and saved on every change. - The page reads coach.today, refreshes it when a run finishes or the tab becomes visible, and checks habits off optimistically; the date header uses the user's time zone from the profile. - Removes the Mission Control link and its constant. - Tests: helper unit tests and a web E2E that writes the brief through the main bot, checks a habit off across a reload and sets check-in times (screenshots heute-coach and heute-rhythm). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test(onboarding): walk the onboarding questions end to end; mobile reads cards as text - onboarding-intake.spec walks focus, goal (after typing into the composer, which still works), check-ins, quiet hours and the notifications card when the browser offers it, with the onboarding-intake-goal, -rhythm and -done screenshots. - completeOnboarding and the wizard specs drop the preferences step; the scan step is optional (skipped when it asks, otherwise it advances by itself). - The user-profile spec now checks the browser-filled profile and the focus fact; the conversation spec checks the shorter app suggestion and the following goal question. - The hover spec measures a reply that fills its row (a bot row's rail sits at the row's end, so the old long onboarding line was what made it pass) and scopes Send to the composer, since an open goal card has its own. - Mobile shows a choice card as its question and answer instead of an empty bubble. - The focus options drop their unused summary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test(web): stub the Notification permission without a static-only class Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test(web): find the sidebar Today button by its English label The shell contrast test added on main looks up the sidebar button by the old German source text, which this branch replaces with "Today". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(onboarding): show the onboarding cards and check-in names in the user's language The onboarding cards, the closing line and the coach routine names were English server text for every user. The server keeps the state and an English fallback; the web now words them through Lingui, so there is one translation source. - Choice cards: the web says the question, the option labels and the placeholder from the step and option ids, and keeps the server text for anything it does not know (quiet-hour ranges are already written in the user's locale). Another bot's focus card is recognized by its option ids. Mobile and agent history keep the server text. - The "Got it. … are a good place to start." line is gone: the app cards speak for themselves, and with no cards nothing is said. - The closing line becomes an intake_note block (note "done", nextCheckInAt, English text). The web words it with the first check-in in the user's clock; mobile blockText and blocksToAgentHistoryText read its text through their generic fallback. - Routines carry coachKey. The routine list and editor show a check-in under its label in the UI language until the user renames it; saving an untouched label keeps the stored name. The English names move to contracts (COACH_ROUTINE_NAMES); prompts stay English. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * style: use top-level type imports in the coach changes AGENTS.md asks for top-level `import type` instead of inline `type` specifiers; this applies it to the import statements the coach work added or changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * chore(web): extract catalogs and translate the coach strings to German Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test(web): look for the bot reply in the transcript only The main conversation's sidebar row now previews the latest reply, so the unscoped text lookup matched the sidebar too and failed in strict mode. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test(web): drop the trading status from the shell contrast test main's contrast test now waits for the sidebar trading status before measuring; this branch removes that status. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test(onboarding): link the foreign folder while its tenant owns the deployment Since #112 only the deployment owner can link a host folder, so the discovery test creates the other tenant's linked space while that tenant holds the deployment, then hands the deployment to the scanning owner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * chore(web): extract catalogs and translate the in-app browser strings Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test: look up the home main conversation in the scheduling journey Since every project has its own main conversation, the journey that schedules for a specialist found the user's main conversation with findFirst over all of them. With the Lab project's conversation also matching, it picked either one, so the check failed about one run in three. The lookup now names the home space, where the specialist joined and the routine is created. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test: expect the home main conversation's ask in another space's inbox Every project now has a main conversation of its own, so an ask waiting in the home main conversation is not visible from a project and its inbox lists it, answered in the home space. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * fix(coach): move check-ins when the user's time zone changes The coach check-ins are main-bot routines scheduled in the profile time zone, but a later zone change (About you, onboarding, the main bot's update_user_profile tool, or forgetting the whole profile) left them in the old zone until the rhythm was saved again, so they fired at the wrong local time. syncCoachTimeZone, next to the rhythm code, moves the main bot's coach routines that are not in the current profile zone into it, recomputes the next run of armed ones and re-arms their wakeup. Paused check-ins move but stay paused, routines the user made are never touched, users without check-ins get none, and a save that leaves the zone as it is changes nothing. The profile update and forget RPCs and the profile tool call it whenever they write the zone. Quiet hours, the daily nudge cap and coach dates are evaluated in the profile zone when they are used, so they need no migration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * fix(web): word the main conversation preview in the user's language during onboarding While onboarding wrote the main conversation, its sidebar row, project rows and Heute showed the server preview: nothing for a pending card (a card has no text of its own, so the row fell back to "Ready") and the English fallback of the closing note, even for a German user. Groups now carry `previewBlock` next to `preview`: the latest message when it is an onboarding card or note, validated with the same block schemas the messages use (now named exports). The web words it with the helpers that render the card and the note, so no copy is duplicated: the question while a card waits, the answer once given, and the closing note in the user's language and clock. A dismissed card keeps the plain preview, as the conversation no longer shows it. The field is left out for every other message, and mobile keeps using the plain `preview`. The chosen-answer lookup moves from the card into onboarding-copy so the card and the preview share it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test(web): look for the focus question in the conversation, not the sidebar The main conversation's sidebar row now shows the pending intake question, so a page-wide text match finds it twice (strict mode) or never reaches zero. The specs mean the card in the open conversation, so they look there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --------- Co-authored-by: Claude <noreply@anthropic.com>
7976 lines
313 KiB
TypeScript
7976 lines
313 KiB
TypeScript
import {
|
||
closestCenter,
|
||
DndContext,
|
||
DragOverlay,
|
||
MouseSensor,
|
||
TouchSensor,
|
||
useSensor,
|
||
useSensors,
|
||
} from "@dnd-kit/core";
|
||
import {
|
||
SortableContext,
|
||
sortableKeyboardCoordinates,
|
||
verticalListSortingStrategy,
|
||
} from "@dnd-kit/sortable";
|
||
import { CSS as DndCSS } from "@dnd-kit/utilities";
|
||
import { i18n } from "@lingui/core";
|
||
import { t } from "@lingui/core/macro";
|
||
import { Trans, useLingui } from "@lingui/react/macro";
|
||
import { ChatMarkdown } from "@rakazo/chat-ui/web";
|
||
import type {
|
||
AgentSkillCatalogEntry,
|
||
Bot,
|
||
BotSection,
|
||
ComputerMode,
|
||
ComputerReleaseReason,
|
||
ComputerStatus,
|
||
Connection,
|
||
ConnectionCatalogItem,
|
||
Group,
|
||
MainConversationRef,
|
||
Me,
|
||
ProductEvent,
|
||
Routine,
|
||
SearchHit,
|
||
Space,
|
||
SpaceMemoryConfig,
|
||
TaughtSkill,
|
||
ThreadMessage,
|
||
ThreadSnapshot,
|
||
VoiceStatus,
|
||
} from "@rakazo/contracts";
|
||
import {
|
||
ATTACHMENT_ALLOWED_MIME_TYPES,
|
||
ATTACHMENT_MAX_BYTES,
|
||
ATTACHMENT_MAX_COUNT,
|
||
canReactToThreadMessage,
|
||
MESSAGE_REACTIONS,
|
||
type MessageReaction,
|
||
normalizeCreateBotProfile,
|
||
} from "@rakazo/contracts";
|
||
import {
|
||
applyWorkspaceFocus,
|
||
attachmentsForThread,
|
||
buildComposerMentionOptions,
|
||
type ComposerMention,
|
||
clampMentionHighlightIndex,
|
||
cronFromPreset,
|
||
deriveWorkspaceFocus,
|
||
groupBotsForSidebar,
|
||
inferAttachmentMimeType,
|
||
isActive,
|
||
isPeerReceiptBlocks,
|
||
isRunTerminalEvent,
|
||
isToolActivityBlock,
|
||
latestAnswerableAskMessageId,
|
||
liveScreenRunId,
|
||
matchVoiceByName,
|
||
mentionChipKey,
|
||
type PaneState,
|
||
parseVoiceChatCommand,
|
||
projectMessageReactions,
|
||
recordScreenPaneChoice,
|
||
reorderBotTo,
|
||
resolveComposerSendPlan,
|
||
resolveMentionPickerKey,
|
||
runThreadSubscription,
|
||
SLASH_ACTIONS,
|
||
type SlashActionId,
|
||
screenPaneMode,
|
||
searchHitThreadTarget,
|
||
serializeComposerPrompt,
|
||
speechFromBlocks,
|
||
truncateSlashDescription,
|
||
userVisibleMessages,
|
||
type WorkspaceArtifact,
|
||
type WorkspaceFocus,
|
||
} from "@rakazo/core";
|
||
import {
|
||
AvatarStyleProvider,
|
||
BotAvatar,
|
||
Button,
|
||
cn,
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
EvooMascot,
|
||
GroupAvatar,
|
||
type GroupAvatarMember,
|
||
InputGroup,
|
||
InputGroupAddon,
|
||
InputGroupInput,
|
||
Popover,
|
||
PopoverContent,
|
||
PopoverTrigger,
|
||
resolvePersonaColorDef,
|
||
Wordmark,
|
||
} from "@rakazo/ui-web";
|
||
import {
|
||
ArrowDown,
|
||
ArrowLeft,
|
||
ArrowUp,
|
||
Bell,
|
||
Box,
|
||
Calendar,
|
||
ChevronDown,
|
||
Clock,
|
||
Copy,
|
||
Folder,
|
||
Gauge,
|
||
Globe,
|
||
GripVertical,
|
||
LayoutGrid,
|
||
Lock,
|
||
LogOut,
|
||
Maximize2,
|
||
Menu,
|
||
Mic,
|
||
MoreHorizontal,
|
||
PanelLeftClose,
|
||
PanelLeftOpen,
|
||
PanelRight,
|
||
Paperclip,
|
||
Pencil,
|
||
Plus,
|
||
Puzzle,
|
||
RefreshCw,
|
||
Reply,
|
||
Search,
|
||
Settings,
|
||
Smile,
|
||
Square,
|
||
TextQuote,
|
||
Trash2,
|
||
Volume2,
|
||
X,
|
||
} from "lucide-react";
|
||
import {
|
||
type ClipboardEvent,
|
||
type DragEvent,
|
||
type MutableRefObject,
|
||
memo,
|
||
type RefObject,
|
||
Suspense,
|
||
useCallback,
|
||
useEffect,
|
||
useId,
|
||
useLayoutEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
import { createPortal } from "react-dom";
|
||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||
import { ArtifactFileCard } from "../components/ArtifactFileCard";
|
||
import { ArtifactPane } from "../components/ArtifactPane";
|
||
import { AskCard } from "../components/AskCard";
|
||
import { ActiveBotGlyph, CollaborationMarker } from "../components/ai/CollaborationMarker";
|
||
import { DelegatedTaskCard, SentMessageCard } from "../components/ai/DelegatedTaskCard";
|
||
import { ProjectCreatedCard } from "../components/ai/ProjectCreatedCard";
|
||
import { InAppBrowser } from "../components/browser/InAppBrowser";
|
||
import { CloudAgentCard } from "../components/CloudAgentCard";
|
||
import { ComputerMaintenanceActions } from "../components/ComputerMaintenanceActions";
|
||
import {
|
||
ComputersUnavailableHint,
|
||
computersAreUnavailable,
|
||
} from "../components/ComputersUnavailableHint";
|
||
import { ComputerUpdateProgress } from "../components/ComputerUpdateProgress";
|
||
import { AgentCursor } from "../components/computer/AgentCursor";
|
||
import { MainInbox } from "../components/MainInbox";
|
||
import { MessageHoverMetadata } from "../components/MessageHoverMetadata";
|
||
import { OoGroupStage, OoStage } from "../components/stage/OoStage";
|
||
import { SkillDraftCard } from "../components/teach/SkillDraftCard";
|
||
import { TeachCaptureOverlay } from "../components/teach/TeachCaptureOverlay";
|
||
import { TeachComputerOverlayControl } from "../components/teach/TeachComputerOverlay";
|
||
import { TeachRecordingChrome, TeachStopButton } from "../components/teach/TeachRecordingChrome";
|
||
import { VoiceAuroraRim } from "../components/voice/VoiceAurora";
|
||
import { useSidebarResize } from "../hooks/useSidebarResize";
|
||
import { useWideLayout } from "../hooks/useWideLayout";
|
||
import { readActivityMode, writeActivityMode } from "../lib/activity-mode";
|
||
import { needsYouChipLabel, rosterActivitySubtitle, rosterStatusDot } from "../lib/agent-activity";
|
||
import { agentPointerFeed } from "../lib/agent-pointer";
|
||
import type { ArtifactTarget } from "../lib/artifact-open";
|
||
import { authClient } from "../lib/auth";
|
||
import { takeInitialBootstrap } from "../lib/bootstrap";
|
||
import {
|
||
BOTS_SIDEBAR_EDGE_DRAG_PX,
|
||
readBotsSidebarCollapsed,
|
||
writeBotsSidebarCollapsed,
|
||
} from "../lib/bots-sidebar-pref";
|
||
import {
|
||
deliverBrowserNotification as deliverNativeBrowserNotification,
|
||
requestBrowserNotificationPermission,
|
||
shouldNotifyBrowser,
|
||
} from "../lib/browser-notifications";
|
||
import { loadComputerScreen } from "../lib/computer-screen";
|
||
import { desktopBridge } from "../lib/desktop";
|
||
import { scheduleFocusPrompt } from "../lib/focus-prompt";
|
||
import {
|
||
isWorkingRunStatus,
|
||
resolveActiveGroupSpeaker,
|
||
resolveWorkingGroupBots,
|
||
} from "../lib/group-stage";
|
||
import { buildHeuteBrief } from "../lib/heute-brief";
|
||
import { lazyWithRecovery } from "../lib/lazy-recovery";
|
||
import { localTimezone } from "../lib/local-timezone";
|
||
import { copyableMessageText } from "../lib/message-text";
|
||
import { messageProviderLabel } from "../lib/messaging";
|
||
import { localizedPreview, withIntakeNoteText } from "../lib/onboarding-copy";
|
||
import { wireOoAudioVoiceDucking } from "../lib/oo-audio-voice-wire";
|
||
import { ooSound } from "../lib/oo-sound";
|
||
import { ooVocals } from "../lib/oo-vocals";
|
||
import { ooVoiceMark } from "../lib/oo-voice-mark";
|
||
import {
|
||
isFileDrag,
|
||
isFilePaste,
|
||
revokePendingAttachmentPreviews,
|
||
} from "../lib/pending-attachments";
|
||
import { markAfterPaint, markOnce } from "../lib/performance";
|
||
import { isEmptyProjectSpace } from "../lib/project-empty-state";
|
||
import { readProjectSidebarOrder, writeProjectSidebarOrder } from "../lib/project-sidebar-order";
|
||
import { quoteDraftForSelection } from "../lib/quote-selection";
|
||
import { routineSaveName } from "../lib/routine-name";
|
||
import { clearSpaceSelection, rpc, selectedSpaceId, selectSpace } from "../lib/rpc";
|
||
import { readSeenRunErrorIds, rememberSeenRunErrorId } from "../lib/run-error-storage";
|
||
import { sharedInflight } from "../lib/shared-inflight";
|
||
import {
|
||
activeThreadRuns,
|
||
applyThreadSendReceipt,
|
||
clearActiveThreadRuns,
|
||
computerPanelAutoBoot,
|
||
computerPanelAutoUsesBoot,
|
||
computerPanelNeedsMaintenance,
|
||
computerTakeoverBlocked,
|
||
isComputerStatusEvent,
|
||
isThreadSnapshotEvent,
|
||
prependThreadMessagePage,
|
||
reconcileRefreshedThread,
|
||
reduceComputerStatus,
|
||
reduceThreadSnapshot,
|
||
threadRunError,
|
||
userHoldsComputerControl,
|
||
} from "../lib/thread-events";
|
||
import {
|
||
transcriptCanSnapAfterFrame,
|
||
transcriptIsNearEnd,
|
||
transcriptMovedDown,
|
||
} from "../lib/transcript-scroll";
|
||
import { speaker } from "../lib/tts";
|
||
import { disableWebPush } from "../lib/web-push";
|
||
import { readScreenPaneHabit, writeScreenPaneHabit } from "../lib/workspace-pane-pref";
|
||
import { ActivityList } from "./ActivityList";
|
||
import type { ContextMenuPosition } from "./BotContextMenu";
|
||
import { CreateGroupForm, GroupSettings, memberName } from "./GroupPanel";
|
||
import { HeuteBrief } from "./HeuteBrief";
|
||
import { HostComputerPrompt } from "./HostComputerPrompt";
|
||
import { ProjectSessionTimeline } from "./ProjectSessionTimeline";
|
||
import {
|
||
draftFromRoutine,
|
||
emptyRoutineDraft,
|
||
type RoutineDraftState,
|
||
RoutineEditor,
|
||
RoutineListHeader,
|
||
RoutineListRow,
|
||
routineNeedsOneShotArm,
|
||
} from "./RoutineEditor";
|
||
import type { SettingsSection } from "./SettingsOverlay";
|
||
import { SpaceSearchResults } from "./SpaceSearch";
|
||
import { BotSettings, CreateBotForm } from "./shell/bot-panel";
|
||
import { BotCreatePicker, ConversationBotPicker } from "./shell/bot-picker";
|
||
import { CommandPalette, isCommandPaletteHotkey } from "./shell/command-palette";
|
||
import {
|
||
ClearConversationDialog,
|
||
DeleteBotDialog,
|
||
DeleteItemDialog,
|
||
NewBotSectionDialog,
|
||
NewSpaceDialog,
|
||
PickerInfoDialog,
|
||
RenameBotSectionDialog,
|
||
} from "./shell/dialogs";
|
||
import { MainConversationRow } from "./shell/main-conversation-row";
|
||
import {
|
||
AppConnectCard,
|
||
ArtifactImage,
|
||
ChartBlockView,
|
||
ChoiceCard,
|
||
McpApprovalCard,
|
||
} from "./shell/message-cards";
|
||
import {
|
||
rosterNameClass,
|
||
rosterPreviewClass,
|
||
rosterRowClass,
|
||
rosterTimeClass,
|
||
rosterUnreadDotClass,
|
||
} from "./shell/roster-row-style";
|
||
import { WindowChrome } from "./WindowChrome";
|
||
|
||
const BotContextMenu = lazyWithRecovery("BotContextMenu", () =>
|
||
import("./BotContextMenu").then((module) => ({ default: module.BotContextMenu })),
|
||
);
|
||
const MessagingSettingsOverlay = lazyWithRecovery("MessagingSettingsOverlay", () =>
|
||
import("./MessagingSettingsOverlay").then((module) => ({
|
||
default: module.MessagingSettingsOverlay,
|
||
})),
|
||
);
|
||
const SettingsOverlay = lazyWithRecovery("SettingsOverlay", () =>
|
||
import("./SettingsOverlay").then((module) => ({ default: module.SettingsOverlay })),
|
||
);
|
||
const PeerMessagesOverlay = lazyWithRecovery("PeerMessagesOverlay", () =>
|
||
import("./PeerMessagesOverlay").then((module) => ({ default: module.PeerMessagesOverlay })),
|
||
);
|
||
const PluginsOverlay = lazyWithRecovery("PluginsOverlay", () =>
|
||
import("./PluginsOverlay").then((module) => ({ default: module.PluginsOverlay })),
|
||
);
|
||
const McpServersOverlay = lazyWithRecovery("McpServersOverlay", () =>
|
||
import("./McpServersOverlay").then((module) => ({ default: module.McpServersOverlay })),
|
||
);
|
||
const CallView = lazyWithRecovery("CallView", () =>
|
||
import("./CallView").then((module) => ({ default: module.CallView })),
|
||
);
|
||
|
||
type Panel =
|
||
| "computer"
|
||
| "settings"
|
||
| "routine"
|
||
| "create"
|
||
| "create-group"
|
||
| "group-settings"
|
||
| "browser"
|
||
| "artifact"
|
||
| null;
|
||
|
||
type PendingAttachment = {
|
||
id: string;
|
||
threadKey: string;
|
||
file: File;
|
||
previewUrl?: string;
|
||
};
|
||
|
||
type PendingBrowserNotification = {
|
||
event: Pick<ProductEvent, "id" | "type" | "threadId" | "botId" | "payload">;
|
||
botId: string;
|
||
botName: string;
|
||
groupNotification: boolean;
|
||
};
|
||
|
||
const ATTACHMENT_ACCEPT = ATTACHMENT_ALLOWED_MIME_TYPES.join(",");
|
||
/** Identity colour for bots the roster no longer knows about. */
|
||
const FALLBACK_BOT_COLOR = "#85858A";
|
||
const THREAD_SNAPSHOT_TIMEOUT_MS = 2_000;
|
||
/** Bound Settings leave so a hung voice status refresh cannot block dismissal. */
|
||
const VOICE_STATUS_REFRESH_TIMEOUT_MS = 10_000;
|
||
const MOBILE_SIDEBAR_SWIPE_EDGE_PX = 32;
|
||
const MOBILE_SIDEBAR_SWIPE_DISTANCE_PX = 56;
|
||
/** The screen lingers after the work so its final state and the bot's reply land first. */
|
||
const AUTO_SCREEN_CLOSE_DELAY_MS = 4_000;
|
||
/** How long one pointer announcement counts as the bot working on its screen. */
|
||
const SCREEN_ACTIVE_MS = 5_000;
|
||
|
||
function defaultWorkspacePaneWidth(): number {
|
||
return Math.round(Math.max(420, Math.min(760, window.innerWidth * 0.42)));
|
||
}
|
||
|
||
function threadSnapshotSignal(parent: AbortSignal): AbortSignal {
|
||
return AbortSignal.any([parent, AbortSignal.timeout(THREAD_SNAPSHOT_TIMEOUT_MS)]);
|
||
}
|
||
|
||
function voiceStatusRefreshTimeout(): Promise<never> {
|
||
return new Promise((_, reject) => {
|
||
AbortSignal.timeout(VOICE_STATUS_REFRESH_TIMEOUT_MS).addEventListener("abort", () => {
|
||
reject(new DOMException("Voice status refresh timed out", "TimeoutError"));
|
||
});
|
||
});
|
||
}
|
||
|
||
function collapsedSidebarSectionsStorageKey(userId: string | null | undefined): string | null {
|
||
if (!userId) return null;
|
||
return `rakazo:collapsed-sidebar-sections:${userId}`;
|
||
}
|
||
|
||
function readCollapsedSidebarSections(userId: string | null | undefined): Set<string> {
|
||
const storageKey = collapsedSidebarSectionsStorageKey(userId);
|
||
if (!storageKey) return new Set();
|
||
try {
|
||
const value = window.localStorage.getItem(storageKey);
|
||
const keys: unknown = value ? JSON.parse(value) : [];
|
||
return new Set(
|
||
Array.isArray(keys) ? keys.filter((key): key is string => typeof key === "string") : [],
|
||
);
|
||
} catch {
|
||
return new Set();
|
||
}
|
||
}
|
||
|
||
export type NavPillar = "heute" | "team" | "projekte";
|
||
|
||
function stripSpaceTitlePrefix(title: string | null, spaceName: string): string | null {
|
||
if (!title) return null;
|
||
const prefix = `${spaceName} · `;
|
||
if (title.startsWith(prefix)) return title.slice(prefix.length);
|
||
if (title === spaceName) return null;
|
||
return title;
|
||
}
|
||
|
||
import { ProjectDragGrip, SortableGroup } from "../components/SortableGroup";
|
||
|
||
export function ShellPage() {
|
||
const { t } = useLingui();
|
||
const { botId, groupId } = useParams();
|
||
const navigate = useNavigate();
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const [activeNavPillar, setActiveNavPillar] = useState<NavPillar>(() => {
|
||
if (groupId) return "projekte";
|
||
if (botId) return "team";
|
||
return "heute";
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (botId) setActiveNavPillar("team");
|
||
}, [botId]);
|
||
|
||
useEffect(() => {
|
||
if (groupId) setActiveNavPillar("projekte");
|
||
}, [groupId]);
|
||
|
||
// Mirrors searchParams for effects that only need to read it once on run,
|
||
// not re-run on every unrelated query-param change (e.g. the SSE subscribe
|
||
// effect below, which should only restart when the active bot changes).
|
||
const searchParamsRef = useRef(searchParams);
|
||
searchParamsRef.current = searchParams;
|
||
const session = authClient.useSession();
|
||
const userId = session.data?.user.id;
|
||
const [groups, setGroups] = useState<Group[]>([]);
|
||
const [bots, setBots] = useState<Bot[]>([]);
|
||
const botsRef = useRef(bots);
|
||
botsRef.current = bots;
|
||
const botOrderEpochRef = useRef(0);
|
||
const pendingBotOrderRef = useRef<string[] | null>(null);
|
||
const savingBotOrderRef = useRef(false);
|
||
const [botSections, setBotSections] = useState<BotSection[]>([]);
|
||
const [spaces, setSpaces] = useState<Space[]>([]);
|
||
const [mainConversation, setMainConversation] = useState<MainConversationRef | null>(null);
|
||
const [archivedBots, setArchivedBots] = useState<Bot[]>([]);
|
||
const [archivedGroups, setArchivedGroups] = useState<Group[]>([]);
|
||
const {
|
||
width: leftSidebarWidth,
|
||
isResizing: isResizingLeft,
|
||
startResizing: startResizingLeft,
|
||
} = useSidebarResize(316, "left", 240, 450, "rakazo:left-sidebar-width");
|
||
const {
|
||
width: rightSidebarWidth,
|
||
isResizing: isResizingRight,
|
||
startResizing: startResizingRight,
|
||
} = useSidebarResize(384, "right", 280, 500, "rakazo:side-panel-width");
|
||
// The screen and presented files get a wider pane than settings: they are what the user watches.
|
||
const {
|
||
width: workspacePaneWidth,
|
||
isResizing: isResizingWorkspacePane,
|
||
startResizing: startResizingWorkspacePane,
|
||
} = useSidebarResize(
|
||
defaultWorkspacePaneWidth(),
|
||
"right",
|
||
360,
|
||
960,
|
||
"rakazo:workspace-pane-width",
|
||
);
|
||
|
||
const [heuteActionCount, setHeuteActionCount] = useState(0);
|
||
const [archivedOpen, setArchivedOpen] = useState(false);
|
||
const [expandedTeamGroups, setExpandedTeamGroups] = useState<Record<string, boolean>>({});
|
||
const [collapsedSidebarSections, setCollapsedSidebarSections] = useState(() => new Set<string>());
|
||
|
||
useEffect(() => {
|
||
setCollapsedSidebarSections(readCollapsedSidebarSections(userId));
|
||
}, [userId]);
|
||
useEffect(() => {
|
||
setBotsSidebarCollapsed(readBotsSidebarCollapsed(userId));
|
||
}, [userId]);
|
||
|
||
const [panel, setPanel] = useState<Panel>(null);
|
||
/** The pane the conversation's focus opened; cleared as soon as the user changes the pane. */
|
||
const [autoPanel, setAutoPanel] = useState<Panel>(null);
|
||
const [artifactPane, setArtifactPane] = useState<WorkspaceArtifact | null>(null);
|
||
/** Run that last pointed at the bot's screen; the live screen only follows real screen work. */
|
||
const [screenRunId, setScreenRunId] = useState<string | null>(null);
|
||
const [screenActive, setScreenActive] = useState(false);
|
||
const screenActiveTimer = useRef<number | undefined>(undefined);
|
||
const [screenHabit, setScreenHabit] = useState(0);
|
||
const wideLayout = useWideLayout();
|
||
|
||
const [showCreateBotModal, setShowCreateBotModal] = useState(false);
|
||
|
||
// Persisted project (space) sort order
|
||
const [projectOrder, setProjectOrder] = useState<string[]>(() => readProjectSidebarOrder());
|
||
const handleReorderProjects = useCallback((orderedIds: string[]) => {
|
||
setProjectOrder(orderedIds);
|
||
writeProjectSidebarOrder(orderedIds);
|
||
}, []);
|
||
const [draggedProjectId, setDraggedProjectId] = useState<string | null>(null);
|
||
const sensors = useSensors(
|
||
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
|
||
useSensor(TouchSensor, { activationConstraint: { distance: 8 } }),
|
||
);
|
||
|
||
const [query, setQuery] = useState("");
|
||
const [searchHits, setSearchHits] = useState<SearchHit[]>([]);
|
||
const [searchLoading, setSearchLoading] = useState(false);
|
||
const [snapshot, setSnapshot] = useState<ThreadSnapshot | null>(null);
|
||
const snapshotRef = useRef<ThreadSnapshot | null>(null);
|
||
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
||
const [replyTarget, setReplyTarget] = useState<ThreadMessage | null>(null);
|
||
const [replyQuote, setReplyQuote] = useState<string | null>(null);
|
||
const [sending, setSending] = useState(false);
|
||
const sendsInFlight = useRef(0);
|
||
const [sendError, setSendError] = useState<string | null>(null);
|
||
const [attachmentNotice, setAttachmentNotice] = useState<string | null>(null);
|
||
|
||
// Safety watchdog: ensure sending state is never stuck indefinitely if an RPC hangs or errors out
|
||
useEffect(() => {
|
||
if (!sending) return;
|
||
const timer = setTimeout(() => {
|
||
sendsInFlight.current = 0;
|
||
setSending(false);
|
||
}, 10_000);
|
||
return () => clearTimeout(timer);
|
||
}, [sending]);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const [peerConversation, setPeerConversation] = useState<{
|
||
peerBotId: string;
|
||
peerBotName: string;
|
||
} | null>(null);
|
||
const [routines, setRoutines] = useState<Routine[]>([]);
|
||
const [routinesBotId, setRoutinesBotId] = useState<string | null>(null);
|
||
const [taughtSkills, setTaughtSkills] = useState<TaughtSkill[]>([]);
|
||
const [taughtSkillsBotId, setTaughtSkillsBotId] = useState<string | null>(null);
|
||
const [agentSkills, setAgentSkills] = useState<AgentSkillCatalogEntry[]>([]);
|
||
const [mentionRoutines, setMentionRoutines] = useState<Array<Routine & { botName?: string }>>([]);
|
||
const [mentionConnectors, setMentionConnectors] = useState<
|
||
Array<{
|
||
id: string;
|
||
name: string;
|
||
authStatus: "connected" | "needs_auth";
|
||
connectionId?: string;
|
||
}>
|
||
>([]);
|
||
const [teachBusy, setTeachBusy] = useState(false);
|
||
const [computer, setComputer] = useState<ComputerStatus | null>(null);
|
||
const computerRef = useRef<ComputerStatus | null>(null);
|
||
const threadRefreshEpoch = useRef(0);
|
||
const groupRefreshEpoch = useRef(0);
|
||
const botsRefreshEpoch = useRef(0);
|
||
const botsRefreshApplied = useRef(0);
|
||
const archivedBotsRefreshEpoch = useRef(0);
|
||
const botsRefreshInFlight = useRef(0);
|
||
// A very fast run can finish over SSE while its threads.send response is still
|
||
// returning. Do not let that late receipt resurrect terminal work as queued.
|
||
const terminalRunReceipts = useRef(new Set<string>());
|
||
// Last-known computer/screen per bot, so switching back to an already-seen
|
||
// bot paints its computer pane instantly instead of blanking it while the
|
||
// thread + screen RPCs round-trip again (see refreshThread / refreshComputerScreen).
|
||
const computerCacheRef = useRef(
|
||
new Map<string, { computer: ComputerStatus | null; screenUrl: string | null }>(),
|
||
);
|
||
// Caps computerCacheRef so a long session that opens many distinct bots
|
||
// over time doesn't accumulate one entry per bot forever. Re-inserting on
|
||
// every update keeps Map iteration order as least-recently-used first, so
|
||
// eviction drops the bot that's been out of view longest.
|
||
const COMPUTER_CACHE_LIMIT = 20;
|
||
|
||
function cacheComputerFor(
|
||
botId: string,
|
||
patch: Partial<{ computer: ComputerStatus | null; screenUrl: string | null }>,
|
||
) {
|
||
const cache = computerCacheRef.current;
|
||
const prev = cache.get(botId) ?? { computer: null, screenUrl: null };
|
||
cache.delete(botId);
|
||
cache.set(botId, { ...prev, ...patch });
|
||
if (cache.size > COMPUTER_CACHE_LIMIT) {
|
||
const oldest = cache.keys().next().value;
|
||
if (oldest !== undefined) cache.delete(oldest);
|
||
}
|
||
}
|
||
|
||
function commitSnapshot(next: ThreadSnapshot | null) {
|
||
snapshotRef.current = next;
|
||
setSnapshot(next);
|
||
}
|
||
|
||
function commitComputer(next: ComputerStatus | null) {
|
||
computerRef.current = next;
|
||
setComputer(next);
|
||
}
|
||
|
||
function updateSnapshot(update: (prev: ThreadSnapshot | null) => ThreadSnapshot | null) {
|
||
commitSnapshot(update(snapshotRef.current));
|
||
}
|
||
const [pluginsOpen, setPluginsOpen] = useState(false);
|
||
const [mcpOpen, setMcpOpen] = useState(false);
|
||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||
|
||
const [settingsSection, setSettingsSection] = useState<SettingsSection>("general");
|
||
const [messagingSettingsOpen, setMessagingSettingsOpen] = useState(false);
|
||
const [messagingSurfaceEnabled, setMessagingSurfaceEnabled] = useState(false);
|
||
const [messagingProviders, setMessagingProviders] = useState<string[]>([]);
|
||
const [memoryProviderConfig, setMemoryProviderConfig] = useState<
|
||
SpaceMemoryConfig | null | undefined
|
||
>(undefined);
|
||
const memoryProviderConfigRevision = useRef(0);
|
||
const [callOpen, setCallOpen] = useState(false);
|
||
const [voiceStatus, setVoiceStatus] = useState<VoiceStatus | null>(null);
|
||
const [speakingMessageId, setSpeakingMessageId] = useState<string | null>(null);
|
||
const [dismissedRunErrorIds, setDismissedRunErrorIds] =
|
||
useState<ReadonlySet<string>>(readSeenRunErrorIds);
|
||
const [menuOpen, setMenuOpen] = useState(false);
|
||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||
const mobileSidebarSwipeRef = useRef<{ startX: number; startY: number } | null>(null);
|
||
const [draggedBotId, setDraggedBotId] = useState<string | null>(null);
|
||
const [createMenuOpen, setCreateMenuOpen] = useState(false);
|
||
const [conversationPickerOpen, setConversationPickerOpen] = useState(false);
|
||
const [botsSidebarCollapsed, setBotsSidebarCollapsed] = useState(false);
|
||
const focusPromptAbortRef = useRef<AbortController | null>(null);
|
||
const focusPromptBotIdRef = useRef<string | null>(null);
|
||
const botsSidebarEdgeDragRef = useRef<{ startX: number; mode: "expand" | "collapse" } | null>(
|
||
null,
|
||
);
|
||
const [newSpaceOpen, setNewSpaceOpen] = useState(false);
|
||
const [newSpaceDialogVariant, setNewSpaceDialogVariant] = useState<"space" | "project">("space");
|
||
const openNewSpaceDialog = useCallback((variant: "space" | "project") => {
|
||
setNewSpaceDialogVariant(variant);
|
||
setNewSpaceOpen(true);
|
||
}, []);
|
||
const [pickerInfoTopic, setPickerInfoTopic] = useState<"group" | "space" | null>(null);
|
||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||
|
||
useEffect(() => {
|
||
const desktop = window.matchMedia("(min-width: 768px)");
|
||
function closeMobileSidebar() {
|
||
if (desktop.matches) setMobileSidebarOpen(false);
|
||
}
|
||
closeMobileSidebar();
|
||
desktop.addEventListener("change", closeMobileSidebar);
|
||
return () => desktop.removeEventListener("change", closeMobileSidebar);
|
||
}, []);
|
||
const [activityMode, setActivityMode] = useState(readActivityMode);
|
||
const toggleActivityMode = useCallback(() => {
|
||
setActivityMode((on) => {
|
||
const next = !on;
|
||
writeActivityMode(next);
|
||
return next;
|
||
});
|
||
}, []);
|
||
const [botMenu, setBotMenu] = useState<{
|
||
kind: "bot" | "group";
|
||
id: string;
|
||
position: ContextMenuPosition;
|
||
} | null>(null);
|
||
// The context menu anchors to the pointer, so return focus to the row that opened it.
|
||
const botMenuAnchor = useRef<HTMLElement | null>(null);
|
||
useEffect(() => {
|
||
if (botMenu || !botMenuAnchor.current) return;
|
||
botMenuAnchor.current.focus();
|
||
botMenuAnchor.current = null;
|
||
}, [botMenu]);
|
||
const [deleteTarget, setDeleteTarget] = useState<Bot | null>(null);
|
||
const [deleteGroupTarget, setDeleteGroupTarget] = useState<Group | null>(null);
|
||
const [deleteSpaceTarget, setDeleteSpaceTarget] = useState<Space | null>(null);
|
||
const [spaceMenu, setSpaceMenu] = useState<{
|
||
id: string;
|
||
position: ContextMenuPosition;
|
||
} | null>(null);
|
||
const spaceMenuAnchor = useRef<HTMLElement | null>(null);
|
||
useEffect(() => {
|
||
if (spaceMenu || !spaceMenuAnchor.current) return;
|
||
spaceMenuAnchor.current.focus();
|
||
spaceMenuAnchor.current = null;
|
||
}, [spaceMenu]);
|
||
const closeSpaceMenu = useCallback(() => setSpaceMenu(null), []);
|
||
const [clearTarget, setClearTarget] = useState<
|
||
{ kind: "bot"; chat: Bot } | { kind: "group"; chat: Group } | null
|
||
>(null);
|
||
const [newSectionTarget, setNewSectionTarget] = useState<
|
||
{ kind: "bot"; chat: Bot } | { kind: "group"; chat: Group } | null
|
||
>(null);
|
||
const [renameSectionTarget, setRenameSectionTarget] = useState<{
|
||
section: BotSection;
|
||
spaceId: string;
|
||
} | null>(null);
|
||
const [sectionMenu, setSectionMenu] = useState<{
|
||
section: BotSection;
|
||
spaceId: string;
|
||
position: ContextMenuPosition;
|
||
} | null>(null);
|
||
const sectionMenuAnchor = useRef<HTMLElement | null>(null);
|
||
useEffect(() => {
|
||
if (sectionMenu || !sectionMenuAnchor.current) return;
|
||
sectionMenuAnchor.current.focus();
|
||
sectionMenuAnchor.current = null;
|
||
}, [sectionMenu]);
|
||
const closeSectionMenu = useCallback(() => setSectionMenu(null), []);
|
||
const [booting, setBooting] = useState(false);
|
||
const [loadingOlder, setLoadingOlder] = useState(false);
|
||
const [initialBotsLoaded, setInitialBotsLoaded] = useState(false);
|
||
const [bootstrapMe, setBootstrapMe] = useState<Me | null>();
|
||
const [routineDraft, setRoutineDraft] = useState<RoutineDraftState>(emptyRoutineDraft());
|
||
const [routineWebhookSecret, setRoutineWebhookSecret] = useState<string | null>(null);
|
||
const [editingRoutine, setEditingRoutine] = useState<Routine | null>(null);
|
||
const [deleteRoutineTarget, setDeleteRoutineTarget] = useState<Routine | null>(null);
|
||
const [savingRoutine, setSavingRoutine] = useState(false);
|
||
const [runningRoutine, setRunningRoutine] = useState(false);
|
||
const [routineError, setRoutineError] = useState<string | null>(null);
|
||
const [screenUrl, setScreenUrl] = useState<string | null>(null);
|
||
const [computerOpen, setComputerOpen] = useState(false);
|
||
const [computerViewport, setComputerViewport] = useState<{
|
||
height: number;
|
||
offsetTop: number;
|
||
} | null>(null);
|
||
const [computerError, setComputerError] = useState<string | null>(null);
|
||
// Screen-load failures can sit beside a still-valid embed URL; boot and
|
||
// takeover failures must stay visible even when a URL remains.
|
||
const [computerErrorFromScreen, setComputerErrorFromScreen] = useState(false);
|
||
useEffect(() => {
|
||
if (!computerOpen) {
|
||
setComputerViewport(null);
|
||
return;
|
||
}
|
||
const viewport = window.visualViewport;
|
||
if (!viewport) return;
|
||
const sync = () => {
|
||
const height = Math.round(viewport.height);
|
||
const offsetTop = Math.round(viewport.offsetTop);
|
||
// Desktop scrollbars and sub-pixel viewport events must not rewrite the
|
||
// overlay height: that feedback loop moves the maintenance menu forever.
|
||
const keyboardInset = Math.round(window.innerHeight) - height - offsetTop;
|
||
if (keyboardInset < 80) {
|
||
setComputerViewport((current) => (current === null ? current : null));
|
||
return;
|
||
}
|
||
setComputerViewport((current) => {
|
||
if (current && current.height === height && current.offsetTop === offsetTop) return current;
|
||
return { height, offsetTop };
|
||
});
|
||
};
|
||
sync();
|
||
viewport.addEventListener("resize", sync);
|
||
viewport.addEventListener("scroll", sync);
|
||
return () => {
|
||
viewport.removeEventListener("resize", sync);
|
||
viewport.removeEventListener("scroll", sync);
|
||
};
|
||
}, [computerOpen]);
|
||
useEffect(() => {
|
||
if (!session.data?.user) return;
|
||
let cancelled = false;
|
||
void rpc.messaging
|
||
.status()
|
||
.then((status) => {
|
||
if (!cancelled) {
|
||
setMessagingSurfaceEnabled(status.enabled);
|
||
setMessagingProviders(status.providers);
|
||
}
|
||
})
|
||
.catch(() => undefined);
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [session.data?.user]);
|
||
const [usage, setUsage] = useState<{
|
||
inputTokens: number;
|
||
outputTokens: number;
|
||
runs: number;
|
||
} | null>(null);
|
||
const autoBooted = useRef<string | null>(null);
|
||
const routineSavePending = useRef(false);
|
||
const webhookSecretProvisionRef = useRef(new Map<string, Promise<string>>());
|
||
const ensureWebhookSecret = (botId: string) =>
|
||
sharedInflight(webhookSecretProvisionRef.current, botId, async () => {
|
||
const result = await rpc.bots.rotateWebhookSecret({ botId });
|
||
setRoutineWebhookSecret(result.secret);
|
||
setBots((current) =>
|
||
current.map((bot) => (bot.id === botId ? { ...bot, webhookConfigured: true } : bot)),
|
||
);
|
||
return result.secret;
|
||
});
|
||
const routineSaveRequest = useRef(0);
|
||
const routineRunPending = useRef(false);
|
||
const bootstrappedThread = useRef<ThreadSnapshot | null>(null);
|
||
const expandedHistoryThread = useRef<string | null>(null);
|
||
const historyEpoch = useRef(0);
|
||
const jumpGeneration = useRef(0);
|
||
const initiallyScrolledThread = useRef<string | null>(null);
|
||
const messageScroll = useRef<HTMLDivElement>(null);
|
||
const pinnedAroundRef = useRef<{
|
||
botId?: string;
|
||
groupId?: string;
|
||
messageId: string;
|
||
threadId: string;
|
||
messages: ThreadMessage[];
|
||
olderCursor: number | null;
|
||
} | null>(null);
|
||
const manuallyUnread = useRef(new Set<string>());
|
||
const readVisibleGroups = useRef(new Set<string>());
|
||
const notifiedBrowserEvents = useRef(new Set<string>());
|
||
const pendingBrowserNotifications = useRef(new Map<string, PendingBrowserNotification>());
|
||
const computerVisible = useRef(false);
|
||
computerVisible.current = panel === "computer" || computerOpen;
|
||
const autoSpoken = useRef<string | null>(null);
|
||
const autoSpokenBotId = useRef<string | null>(null);
|
||
|
||
const inGroup = Boolean(groupId);
|
||
const projectSessionKey = searchParams.get("s");
|
||
const voiceEntry = searchParams.get("voice") === "1";
|
||
const currentSpaceId = selectedSpaceId() ?? bootstrapMe?.spaceId;
|
||
const homeSpaceId = spaces.find((space) => space.isDefault)?.id ?? bootstrapMe?.spaceId;
|
||
const emptyProjectSpace = useMemo(
|
||
() => isEmptyProjectSpace(currentSpaceId, spaces, groups, homeSpaceId),
|
||
[currentSpaceId, groups, homeSpaceId, spaces],
|
||
);
|
||
const active = inGroup
|
||
? undefined
|
||
: (bots.find((b) => b.id === botId) ?? (emptyProjectSpace && !botId ? undefined : bots[0]));
|
||
const activeGroup = groups.find((group) => group.id === groupId);
|
||
const activeProjectSection = activeGroup?.sectionId
|
||
? botSections.find((section) => section.id === activeGroup.sectionId)
|
||
: undefined;
|
||
const showProjectTimeline =
|
||
inGroup && activeNavPillar !== "heute" && !projectSessionKey && Boolean(activeGroup?.sectionId);
|
||
const activePendingAttachments = useMemo(
|
||
() => attachmentsForThread(pendingAttachments, inGroup ? groupId : active?.id),
|
||
[active?.id, groupId, inGroup, pendingAttachments],
|
||
);
|
||
const activeRoutines = !inGroup && routinesBotId === active?.id ? routines : [];
|
||
const activeTaughtSkills = taughtSkillsBotId === active?.id ? taughtSkills : [];
|
||
const recordingSkill = activeTaughtSkills.find((skill) => skill.status === "recording") ?? null;
|
||
const routeBotId = useRef<string | undefined>(botId);
|
||
routeBotId.current = botId;
|
||
const routeGroupId = useRef<string | undefined>(groupId);
|
||
routeGroupId.current = groupId;
|
||
const activeBotId = useRef<string | undefined>(inGroup ? undefined : active?.id);
|
||
activeBotId.current = inGroup ? undefined : active?.id;
|
||
const activeGroupId = useRef<string | undefined>(groupId);
|
||
activeGroupId.current = groupId;
|
||
const screenRequest = useRef(0);
|
||
const contextBot =
|
||
botMenu?.kind === "bot" ? bots.find((bot) => bot.id === botMenu.id) : undefined;
|
||
const contextGroup =
|
||
botMenu?.kind === "group" ? groups.find((group) => group.id === botMenu.id) : undefined;
|
||
const contextChat = contextBot ?? contextGroup;
|
||
const closeBotMenu = useCallback(() => setBotMenu(null), []);
|
||
const updateBotUnread = useCallback((id: string, unread: boolean) => {
|
||
setBots((current) => {
|
||
const bot = current.find((candidate) => candidate.id === id);
|
||
if (!bot || bot.unread === unread) return current;
|
||
return current.map((candidate) =>
|
||
candidate.id === id ? { ...candidate, unread } : candidate,
|
||
);
|
||
});
|
||
}, []);
|
||
const markBotRead = useCallback(
|
||
async (id: string) => {
|
||
await rpc.threads.markRead({ botId: id });
|
||
manuallyUnread.current.delete(id);
|
||
updateBotUnread(id, false);
|
||
},
|
||
[updateBotUnread],
|
||
);
|
||
const markBotUnread = useCallback(
|
||
async (id: string) => {
|
||
manuallyUnread.current.add(id);
|
||
try {
|
||
await rpc.threads.markUnread({ botId: id });
|
||
} catch (err) {
|
||
manuallyUnread.current.delete(id);
|
||
throw err;
|
||
}
|
||
updateBotUnread(id, true);
|
||
},
|
||
[updateBotUnread],
|
||
);
|
||
// A bot the user marked unread by hand stays unread until they open it again,
|
||
// otherwise the auto-read below would undo the action on the next window focus.
|
||
const markBotReadIfVisible = useCallback(
|
||
(id: string) => {
|
||
if (manuallyUnread.current.has(id)) return;
|
||
if (document.visibilityState === "visible" && document.hasFocus()) {
|
||
void markBotRead(id).catch(() => undefined);
|
||
}
|
||
},
|
||
[markBotRead],
|
||
);
|
||
const deliverBrowserNotification = useCallback((pending: PendingBrowserNotification): boolean => {
|
||
const currentBot = botsRef.current.find((bot) => bot.id === pending.botId);
|
||
if (!currentBot || typeof Notification === "undefined") return true;
|
||
const result = deliverNativeBrowserNotification(
|
||
pending.event,
|
||
currentBot.name || pending.botName,
|
||
{
|
||
enabled: pending.groupNotification || currentBot.notifyOnFinish,
|
||
pageVisible: document.visibilityState === "visible",
|
||
windowFocused: document.hasFocus(),
|
||
permission: Notification.permission,
|
||
notifiedEventIds: notifiedBrowserEvents.current,
|
||
show: (title, body, tag) => new Notification(title, { body, tag }),
|
||
},
|
||
);
|
||
return result !== "pending";
|
||
}, []);
|
||
const flushPendingBrowserNotifications = useCallback(() => {
|
||
for (const [threadId, pending] of pendingBrowserNotifications.current) {
|
||
if (deliverBrowserNotification(pending)) {
|
||
pendingBrowserNotifications.current.delete(threadId);
|
||
}
|
||
}
|
||
}, [deliverBrowserNotification]);
|
||
const notifyBrowserForEvent = useCallback(
|
||
(
|
||
event: Pick<ProductEvent, "id" | "type" | "threadId" | "seq" | "botId" | "payload">,
|
||
subscribedThreadId: string | undefined,
|
||
initialCursor: number,
|
||
streamReady: boolean,
|
||
botName: string,
|
||
enabled: boolean,
|
||
groupNotification: boolean,
|
||
) => {
|
||
const botId = event.botId;
|
||
if (typeof botId !== "string") return;
|
||
const eligible = shouldNotifyBrowser(event, {
|
||
subscribedThreadId: subscribedThreadId ?? "",
|
||
initialCursor,
|
||
streamReady,
|
||
pageVisible: document.visibilityState === "visible",
|
||
windowFocused: document.hasFocus(),
|
||
permission: "granted",
|
||
notifiedEventIds: notifiedBrowserEvents.current,
|
||
});
|
||
if (!eligible || !enabled) return;
|
||
const pending = {
|
||
event,
|
||
botId,
|
||
botName,
|
||
groupNotification,
|
||
} satisfies PendingBrowserNotification;
|
||
if (typeof Notification === "undefined" || Notification.permission === "denied") return;
|
||
if (Notification.permission === "default") {
|
||
pendingBrowserNotifications.current.set(event.threadId, pending);
|
||
return;
|
||
}
|
||
if (deliverBrowserNotification(pending)) {
|
||
pendingBrowserNotifications.current.delete(event.threadId);
|
||
} else {
|
||
pendingBrowserNotifications.current.set(event.threadId, pending);
|
||
}
|
||
},
|
||
[deliverBrowserNotification],
|
||
);
|
||
|
||
const refreshBots = useCallback(
|
||
async (includeArchived = false, replaceBotOrder = false) => {
|
||
markOnce("rk:renderer:bots-request-start");
|
||
const request = ++botsRefreshEpoch.current;
|
||
const botOrderEpoch = botOrderEpochRef.current;
|
||
const preserveBotOrder = savingBotOrderRef.current || pendingBotOrderRef.current !== null;
|
||
const archivedRequest = includeArchived ? ++archivedBotsRefreshEpoch.current : null;
|
||
botsRefreshInFlight.current += 1;
|
||
try {
|
||
const [navigation, archived, archivedGroupList] = await Promise.all([
|
||
rpc.spaces.list(),
|
||
includeArchived ? rpc.bots.listArchived() : Promise.resolve(null),
|
||
includeArchived ? rpc.groups.listArchived() : Promise.resolve(null),
|
||
]);
|
||
const { bots: list, botSections: sections, groups: groupList } = navigation.current;
|
||
markOnce("rk:renderer:bots-response");
|
||
const botsFresh = request === botsRefreshEpoch.current;
|
||
const archivedFresh =
|
||
archivedRequest != null && archivedRequest === archivedBotsRefreshEpoch.current;
|
||
// A newer non-archived refresh can win the bots epoch while an older
|
||
// includeArchived request still owns archivedBotsRefreshEpoch — apply
|
||
// whichever slices are still current.
|
||
if (!botsFresh && !archivedFresh) return;
|
||
if (archivedFresh && archived) setArchivedBots(archived);
|
||
if (archivedFresh && archivedGroupList) setArchivedGroups(archivedGroupList);
|
||
if (!botsFresh) return;
|
||
if (
|
||
botOrderEpoch === botOrderEpochRef.current &&
|
||
(replaceBotOrder || (!preserveBotOrder && !savingBotOrderRef.current))
|
||
) {
|
||
setBots(list);
|
||
}
|
||
setBotSections(sections);
|
||
setGroups(groupList);
|
||
setSpaces(navigation.spaces);
|
||
setMainConversation(navigation.main);
|
||
setInitialBotsLoaded(true);
|
||
botsRefreshApplied.current = request;
|
||
if (
|
||
includeArchived &&
|
||
list.length === 0 &&
|
||
archived?.length === 0 &&
|
||
groupList.length === 0 &&
|
||
archivedGroupList?.length === 0 &&
|
||
!navigation.spaces.some((space) => space.hasContent)
|
||
) {
|
||
// Only the very first bot everywhere needs onboarding. An empty
|
||
// current space with content elsewhere stays in the app so the
|
||
// space can be switched to or deleted instead of trapping the user.
|
||
navigate("/onboarding", { replace: true });
|
||
return;
|
||
}
|
||
const currentGroupId = routeGroupId.current;
|
||
const currentBotId = routeBotId.current;
|
||
const refreshHomeSpaceId =
|
||
navigation.spaces.find((space) => space.isDefault)?.id ?? selectedSpaceId() ?? undefined;
|
||
const refreshSpaceId = selectedSpaceId() ?? refreshHomeSpaceId;
|
||
const emptyProject = isEmptyProjectSpace(
|
||
refreshSpaceId,
|
||
navigation.spaces,
|
||
groupList,
|
||
refreshHomeSpaceId,
|
||
);
|
||
if (emptyProject) {
|
||
if (currentGroupId || (currentBotId && !list.some((bot) => bot.id === currentBotId))) {
|
||
navigate("/app", { replace: true });
|
||
}
|
||
return;
|
||
}
|
||
const inProject = Boolean(refreshSpaceId && refreshSpaceId !== refreshHomeSpaceId);
|
||
if (currentGroupId) {
|
||
if (!groupList.some((group) => group.id === currentGroupId)) {
|
||
navigate(firstThreadRoute(list, groupList, inProject), { replace: true });
|
||
}
|
||
return;
|
||
}
|
||
if (!currentBotId || !list.some((bot) => bot.id === currentBotId)) {
|
||
navigate(firstThreadRoute(list, groupList, inProject), { replace: true });
|
||
}
|
||
} finally {
|
||
botsRefreshInFlight.current -= 1;
|
||
}
|
||
},
|
||
[navigate],
|
||
);
|
||
|
||
function snapTranscriptToEndAfterFrame() {
|
||
const queuedElement = messageScroll.current;
|
||
if (!queuedElement) return;
|
||
const queuedScrollTop = queuedElement.scrollTop;
|
||
window.requestAnimationFrame(() => {
|
||
const element = messageScroll.current;
|
||
if (transcriptCanSnapAfterFrame(element, queuedElement, queuedScrollTop)) {
|
||
queuedElement.scrollTop = queuedElement.scrollHeight;
|
||
}
|
||
});
|
||
}
|
||
|
||
async function refreshGroupThread(id: string, signal?: AbortSignal) {
|
||
const scrollElement = messageScroll.current;
|
||
const stickToEnd = !scrollElement || transcriptIsNearEnd(scrollElement);
|
||
markOnce("rk:renderer:thread-request-start");
|
||
const request = ++groupRefreshEpoch.current;
|
||
const snap = await rpc.threads.get({ groupId: id }, signal ? { signal } : undefined);
|
||
markOnce("rk:renderer:thread-response");
|
||
if (activeGroupId.current !== id || request !== groupRefreshEpoch.current) return snap;
|
||
const reconciled = reconcileRefreshedThread(
|
||
snapshotRef.current,
|
||
snap,
|
||
computerRef.current,
|
||
expandedHistoryThread.current === snap.threadId,
|
||
);
|
||
commitSnapshot(reconciled.snapshot);
|
||
commitComputer(null);
|
||
setRoutines([]);
|
||
setRoutinesBotId(null);
|
||
// Keep the search-jump viewport; expandedHistoryThread merge still accepts live messages.
|
||
if (
|
||
stickToEnd &&
|
||
(!scrollElement || transcriptIsNearEnd(scrollElement)) &&
|
||
expandedHistoryThread.current !== snap.threadId
|
||
) {
|
||
snapTranscriptToEndAfterFrame();
|
||
}
|
||
return snap;
|
||
}
|
||
|
||
async function refreshThread(id: string, signal?: AbortSignal) {
|
||
const scrollElement = messageScroll.current;
|
||
const stickToEnd = !scrollElement || transcriptIsNearEnd(scrollElement);
|
||
markOnce("rk:renderer:thread-request-start");
|
||
const epoch = historyEpoch.current;
|
||
const request = ++threadRefreshEpoch.current;
|
||
// Apply threads.get as soon as it returns so stop/takeover status is not held behind
|
||
// routines/skills/screen fetches (progress can advance the cursor meanwhile).
|
||
const snap = await rpc.threads.get({ botId: id }, signal ? { signal } : undefined);
|
||
markOnce("rk:renderer:thread-response");
|
||
if (
|
||
activeBotId.current !== id ||
|
||
epoch !== historyEpoch.current ||
|
||
request !== threadRefreshEpoch.current
|
||
) {
|
||
return snap;
|
||
}
|
||
const reconciled = reconcileRefreshedThread(
|
||
snapshotRef.current,
|
||
snap,
|
||
computerRef.current,
|
||
expandedHistoryThread.current === snap.threadId,
|
||
);
|
||
commitSnapshot(reconciled.snapshot);
|
||
commitComputer(reconciled.computer);
|
||
cacheComputerFor(id, { computer: reconciled.computer });
|
||
if (
|
||
stickToEnd &&
|
||
(!scrollElement || transcriptIsNearEnd(scrollElement)) &&
|
||
expandedHistoryThread.current !== snap.threadId
|
||
) {
|
||
snapTranscriptToEndAfterFrame();
|
||
}
|
||
void Promise.all([
|
||
rpc.routines.list({ botId: id }).catch(() => null),
|
||
rpc.skills.list({ botId: id }).catch(() => null),
|
||
refreshComputerScreen(id).catch(() => null),
|
||
]).then(([routines, skills]) => {
|
||
if (
|
||
activeBotId.current !== id ||
|
||
epoch !== historyEpoch.current ||
|
||
request !== threadRefreshEpoch.current
|
||
) {
|
||
return;
|
||
}
|
||
if (routines) {
|
||
setRoutines(routines);
|
||
setRoutinesBotId(id);
|
||
}
|
||
if (skills) {
|
||
setTaughtSkills(skills);
|
||
setTaughtSkillsBotId(id);
|
||
}
|
||
});
|
||
return snap;
|
||
}
|
||
|
||
async function refreshComputerScreen(id: string) {
|
||
if (!computerVisible.current) return null;
|
||
const request = ++screenRequest.current;
|
||
return loadComputerScreen({
|
||
load: () => rpc.computer.screenUrl({ botId: id }),
|
||
isCurrent: () =>
|
||
request === screenRequest.current && activeBotId.current === id && computerVisible.current,
|
||
commit: (screen) => {
|
||
setScreenUrl(screen.url);
|
||
setComputerError(screen.error);
|
||
setComputerErrorFromScreen(Boolean(screen.error));
|
||
cacheComputerFor(id, { screenUrl: screen.url });
|
||
},
|
||
fallbackError: t`Could not connect to the computer screen`,
|
||
});
|
||
}
|
||
|
||
async function loadOlderMessages() {
|
||
const targetBotId = inGroup ? undefined : active?.id;
|
||
const targetGroupId = inGroup ? groupId : undefined;
|
||
const snapshotMatchesTarget = targetGroupId
|
||
? snapshot?.groupId === targetGroupId
|
||
: snapshot?.botId === targetBotId;
|
||
if (
|
||
(!targetBotId && !targetGroupId) ||
|
||
!snapshotMatchesTarget ||
|
||
snapshot?.olderCursor == null ||
|
||
loadingOlder
|
||
)
|
||
return;
|
||
pinnedAroundRef.current = null;
|
||
const scrollElement = messageScroll.current;
|
||
const previousHeight = scrollElement?.scrollHeight ?? 0;
|
||
const epoch = historyEpoch.current;
|
||
const before = snapshot.olderCursor;
|
||
setLoadingOlder(true);
|
||
try {
|
||
const page = await rpc.threads.messages({
|
||
...(targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! }),
|
||
before,
|
||
});
|
||
if (
|
||
epoch !== historyEpoch.current ||
|
||
activeBotId.current !== targetBotId ||
|
||
activeGroupId.current !== targetGroupId
|
||
)
|
||
return;
|
||
expandedHistoryThread.current = page.threadId;
|
||
updateSnapshot((prev) => prependThreadMessagePage(prev, page));
|
||
window.requestAnimationFrame(() => {
|
||
const element = messageScroll.current;
|
||
if (element) element.scrollTop += element.scrollHeight - previousHeight;
|
||
});
|
||
} finally {
|
||
setLoadingOlder(false);
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
const providerConfigRevision = memoryProviderConfigRevision.current;
|
||
void rpc.memory
|
||
.providerConfig()
|
||
.then((providerConfig) => {
|
||
if (!cancelled && memoryProviderConfigRevision.current === providerConfigRevision) {
|
||
setMemoryProviderConfig(providerConfig);
|
||
}
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled && memoryProviderConfigRevision.current === providerConfigRevision) {
|
||
setMemoryProviderConfig(null);
|
||
}
|
||
});
|
||
const appliedAtStart = botsRefreshApplied.current;
|
||
void takeInitialBootstrap(botId)
|
||
.then((bootstrap) => {
|
||
if (cancelled) return;
|
||
const groupList = bootstrap.groups;
|
||
setBootstrapMe(bootstrap.me);
|
||
if (!selectedSpaceId()) {
|
||
selectSpace(bootstrap.me.spaceId);
|
||
}
|
||
// Skip list/route writes only if a later refreshBots() successfully
|
||
// committed (failed refreshes bump epoch but not botsRefreshApplied).
|
||
const applyBotLists = appliedAtStart === botsRefreshApplied.current;
|
||
if (applyBotLists) {
|
||
setBots(bootstrap.bots);
|
||
setBotSections(bootstrap.botSections);
|
||
setArchivedBots(bootstrap.archivedBots);
|
||
setArchivedGroups(bootstrap.archivedGroups);
|
||
setGroups(groupList);
|
||
setSpaces(bootstrap.spaces);
|
||
setMainConversation(bootstrap.main);
|
||
setInitialBotsLoaded(true);
|
||
}
|
||
if (!groupId && bootstrap.thread) {
|
||
bootstrappedThread.current = bootstrap.thread;
|
||
commitSnapshot(bootstrap.thread);
|
||
commitComputer(bootstrap.thread.computer ?? null);
|
||
setRoutines(bootstrap.routines);
|
||
setRoutinesBotId(bootstrap.thread.botId ?? null);
|
||
markOnce("rk:renderer:bots-response");
|
||
markOnce("rk:renderer:thread-response");
|
||
}
|
||
if (!applyBotLists) return;
|
||
if (
|
||
bootstrap.bots.length === 0 &&
|
||
bootstrap.archivedBots.length === 0 &&
|
||
groupList.length === 0 &&
|
||
bootstrap.archivedGroups.length === 0 &&
|
||
!bootstrap.spaces.some((space) => space.hasContent)
|
||
) {
|
||
navigate("/onboarding", { replace: true });
|
||
return;
|
||
}
|
||
const bootstrapHomeSpaceId =
|
||
bootstrap.spaces.find((space) => space.isDefault)?.id ?? bootstrap.me.spaceId;
|
||
const bootstrapSpaceId = selectedSpaceId() ?? bootstrap.me.spaceId;
|
||
const emptyProject = isEmptyProjectSpace(
|
||
bootstrapSpaceId,
|
||
bootstrap.spaces,
|
||
groupList,
|
||
bootstrapHomeSpaceId,
|
||
);
|
||
if (emptyProject) {
|
||
if (groupId || (botId && !bootstrap.bots.some((bot) => bot.id === botId))) {
|
||
navigate("/app", { replace: true });
|
||
}
|
||
return;
|
||
}
|
||
const inProject = bootstrapSpaceId !== bootstrapHomeSpaceId;
|
||
if (groupId) {
|
||
if (!groupList.some((group) => group.id === groupId)) {
|
||
navigate(firstThreadRoute(bootstrap.bots, groupList, inProject), { replace: true });
|
||
}
|
||
return;
|
||
}
|
||
if (inProject && !botId) {
|
||
navigate(firstThreadRoute(bootstrap.bots, groupList, true), { replace: true });
|
||
return;
|
||
}
|
||
const selectedBotId = bootstrap.thread?.botId ?? bootstrap.bots[0]?.id;
|
||
if (selectedBotId && selectedBotId !== botId) {
|
||
navigate(`/app/${selectedBotId}`, { replace: true });
|
||
}
|
||
})
|
||
.catch(() => {
|
||
if (cancelled) return;
|
||
setBootstrapMe(null);
|
||
void refreshBots(true);
|
||
});
|
||
let refreshTimer: number | undefined;
|
||
const refreshVisibleBots = () => {
|
||
if (document.visibilityState !== "visible") return;
|
||
window.clearTimeout(refreshTimer);
|
||
refreshTimer = window.setTimeout(() => void refreshBots().catch(() => undefined), 50);
|
||
};
|
||
window.addEventListener("focus", refreshVisibleBots);
|
||
document.addEventListener("visibilitychange", refreshVisibleBots);
|
||
// Poll skips while a refresh is in flight; focus/visibility and event-driven
|
||
// callers still bump botsRefreshEpoch so only the latest response applies.
|
||
const poll = window.setInterval(() => {
|
||
if (botsRefreshInFlight.current > 0) return;
|
||
refreshVisibleBots();
|
||
}, 3_000);
|
||
return () => {
|
||
cancelled = true;
|
||
window.clearTimeout(refreshTimer);
|
||
window.clearInterval(poll);
|
||
window.removeEventListener("focus", refreshVisibleBots);
|
||
document.removeEventListener("visibilitychange", refreshVisibleBots);
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
void rpc.agentSkills
|
||
.list()
|
||
.then((skills) => {
|
||
if (!cancelled) setAgentSkills(skills);
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) setAgentSkills([]);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, []);
|
||
|
||
const refreshAgentSkills = useCallback(() => {
|
||
void rpc.agentSkills
|
||
.list()
|
||
.then(setAgentSkills)
|
||
.catch(() => undefined);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void rpc.voice
|
||
.status()
|
||
.then(setVoiceStatus)
|
||
.catch(() => undefined);
|
||
const unsubSpeech = speaker.subscribe((state) => {
|
||
setSpeakingMessageId(state.status === "idle" ? null : (state.messageId ?? null));
|
||
});
|
||
return () => {
|
||
unsubSpeech();
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!active || !snapshot || snapshot.botId !== active.id) return;
|
||
const lastBot = [...snapshot.messages].reverse().find((message) => message.role === "bot");
|
||
if (autoSpokenBotId.current !== active.id) {
|
||
autoSpokenBotId.current = active.id;
|
||
autoSpoken.current = lastBot?.id ?? null;
|
||
return;
|
||
}
|
||
if (callOpen || !active.autoSpeak) {
|
||
autoSpoken.current = lastBot?.id ?? null;
|
||
return;
|
||
}
|
||
if (snapshot.run && ["running", "queued", "leased"].includes(snapshot.run.status)) return;
|
||
if (!lastBot || lastBot.id === autoSpoken.current) return;
|
||
const text = speechFromBlocks(lastBot.blocks);
|
||
if (!text) return;
|
||
autoSpoken.current = lastBot.id;
|
||
void speaker.speak(text, { botId: active.id, messageId: lastBot.id });
|
||
}, [
|
||
snapshot?.messages,
|
||
snapshot?.run?.status,
|
||
snapshot?.botId,
|
||
active?.autoSpeak,
|
||
active?.id,
|
||
callOpen,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (!active) return;
|
||
// Opening a bot clears the manual unread flag so it can auto-read again.
|
||
manuallyUnread.current.delete(active.id);
|
||
const markVisibleBotRead = () => {
|
||
markBotReadIfVisible(active.id);
|
||
};
|
||
markVisibleBotRead();
|
||
window.addEventListener("focus", markVisibleBotRead);
|
||
document.addEventListener("visibilitychange", markVisibleBotRead);
|
||
return () => {
|
||
window.removeEventListener("focus", markVisibleBotRead);
|
||
document.removeEventListener("visibilitychange", markVisibleBotRead);
|
||
};
|
||
}, [active?.id, markBotReadIfVisible]);
|
||
|
||
useEffect(() => {
|
||
if (!active) return;
|
||
const pendingJump = searchParamsRef.current.get("m");
|
||
if (!pendingJump) {
|
||
pinnedAroundRef.current = null;
|
||
}
|
||
screenRequest.current += 1;
|
||
setComputerError(null);
|
||
setComputerErrorFromScreen(false);
|
||
const cached = computerCacheRef.current.get(active.id);
|
||
if (cached) {
|
||
// Paint the last-known computer instantly; refreshThread/refreshComputerScreen
|
||
// below still run and reconcile with fresh data in the background.
|
||
setScreenUrl(cached.screenUrl);
|
||
commitComputer(cached.computer);
|
||
} else {
|
||
setScreenUrl(null);
|
||
}
|
||
expandedHistoryThread.current = null;
|
||
historyEpoch.current += 1;
|
||
const abort = new AbortController();
|
||
void runThreadSubscription({
|
||
signal: abort.signal,
|
||
loadInitial: async () => {
|
||
const primed = bootstrappedThread.current;
|
||
bootstrappedThread.current = null;
|
||
// Pending search jumps load the around-page separately; avoid replacing it with latest.
|
||
return primed?.botId === active.id
|
||
? primed
|
||
: pendingJump
|
||
? rpc.threads.get({ botId: active.id }, { signal: threadSnapshotSignal(abort.signal) })
|
||
: refreshThread(active.id, threadSnapshotSignal(abort.signal));
|
||
},
|
||
loadHead: () =>
|
||
rpc.threads.head({ botId: active.id }, { signal: threadSnapshotSignal(abort.signal) }),
|
||
refresh: () => refreshThread(active.id, threadSnapshotSignal(abort.signal)),
|
||
currentSnapshot: () => snapshotRef.current,
|
||
subscribe: (cursor) =>
|
||
rpc.threads.subscribe({ botId: active.id, cursor }, { signal: abort.signal }),
|
||
beforeEvent: (event) => {
|
||
if (isRunTerminalEvent(event) && event.runId) {
|
||
terminalRunReceipts.current.add(event.runId);
|
||
if (terminalRunReceipts.current.size > 100) {
|
||
const oldest = terminalRunReceipts.current.values().next().value;
|
||
if (oldest !== undefined) terminalRunReceipts.current.delete(oldest);
|
||
}
|
||
}
|
||
},
|
||
applyEvent: (event) =>
|
||
applyThreadEvent(event, commitSnapshot, commitComputer, snapshotRef, computerRef),
|
||
onEvent: (event, initial) => {
|
||
const currentBot = botsRef.current.find((bot) => bot.id === active.id);
|
||
notifyBrowserForEvent(
|
||
event,
|
||
initial.threadId,
|
||
initial.cursor,
|
||
true,
|
||
currentBot?.name ?? active.name,
|
||
currentBot?.notifyOnFinish ?? false,
|
||
false,
|
||
);
|
||
if (event.type === "thread.cleared") {
|
||
expandedHistoryThread.current = null;
|
||
pinnedAroundRef.current = null;
|
||
historyEpoch.current += 1;
|
||
}
|
||
if (event.type === "bot.archived") {
|
||
void refreshBots(true).catch(() => undefined);
|
||
} else if (
|
||
event.type === "bot.spawned" ||
|
||
event.type === "bot.deleted" ||
|
||
event.type === "run.started" ||
|
||
isRunTerminalEvent(event) ||
|
||
event.type === "thread.cleared"
|
||
) {
|
||
void refreshBots().catch(() => undefined);
|
||
}
|
||
if (event.type === "group.created") {
|
||
void refreshBots().catch(() => undefined);
|
||
}
|
||
if (event.type === "thread.message.created") {
|
||
const blocks = (event.payload.blocks as Array<{ kind?: string }>) ?? [];
|
||
if (
|
||
blocks.some((block) => block.kind === "child_bot" || block.kind === "project_created")
|
||
) {
|
||
void refreshBots().catch(() => undefined);
|
||
}
|
||
if (event.payload.role === "bot") markBotReadIfVisible(active.id);
|
||
}
|
||
if (event.type === "run.waiting_input" || event.type === "computer.takeover.requested") {
|
||
ooSound.play("need-you");
|
||
ooVocals.reactForEvent("need-you");
|
||
}
|
||
if (
|
||
isRunTerminalEvent(event) ||
|
||
event.type === "run.waiting_input" ||
|
||
event.type === "skill.teaching.stopped"
|
||
) {
|
||
// waiting_input: reconcile ask cards if a stale post-send refresh raced SSE.
|
||
void refreshThread(active.id).catch(() => undefined);
|
||
} else if (isComputerStatusEvent(event)) {
|
||
void refreshComputerScreen(active.id).catch(() => undefined);
|
||
} else if (event.type === "thread.computer") {
|
||
agentPointerFeed.publish(event.botId, event.payload, event.createdAt);
|
||
const screenRun = liveScreenRunId(event);
|
||
if (screenRun) {
|
||
setScreenRunId(screenRun);
|
||
setScreenActive(true);
|
||
window.clearTimeout(screenActiveTimer.current);
|
||
screenActiveTimer.current = window.setTimeout(
|
||
() => setScreenActive(false),
|
||
SCREEN_ACTIVE_MS,
|
||
);
|
||
}
|
||
}
|
||
},
|
||
});
|
||
return () => {
|
||
abort.abort();
|
||
};
|
||
}, [active?.id, markBotReadIfVisible, notifyBrowserForEvent]);
|
||
|
||
useEffect(() => {
|
||
if (!groupId || !activeGroup) return;
|
||
manuallyUnread.current.delete(activeGroup.id);
|
||
readVisibleGroups.current.delete(groupId);
|
||
const markVisibleGroupRead = () => {
|
||
if (
|
||
document.visibilityState !== "visible" ||
|
||
!document.hasFocus() ||
|
||
readVisibleGroups.current.has(groupId)
|
||
)
|
||
return;
|
||
readVisibleGroups.current.add(groupId);
|
||
void rpc.threads
|
||
.markRead({ groupId })
|
||
.then(() => {
|
||
setGroups((current) => {
|
||
const group = current.find((candidate) => candidate.id === groupId);
|
||
if (!group?.unread) return current;
|
||
return current.map((candidate) =>
|
||
candidate.id === groupId ? { ...candidate, unread: false } : candidate,
|
||
);
|
||
});
|
||
})
|
||
.catch(() => {
|
||
readVisibleGroups.current.delete(groupId);
|
||
});
|
||
};
|
||
markVisibleGroupRead();
|
||
window.addEventListener("focus", markVisibleGroupRead);
|
||
document.addEventListener("visibilitychange", markVisibleGroupRead);
|
||
const pendingJump = searchParamsRef.current.get("m");
|
||
if (!pendingJump) {
|
||
pinnedAroundRef.current = null;
|
||
expandedHistoryThread.current = null;
|
||
}
|
||
historyEpoch.current += 1;
|
||
const abort = new AbortController();
|
||
void runThreadSubscription({
|
||
signal: abort.signal,
|
||
loadInitial: () =>
|
||
pendingJump
|
||
? rpc.threads.get({ groupId }, { signal: threadSnapshotSignal(abort.signal) })
|
||
: refreshGroupThread(groupId, threadSnapshotSignal(abort.signal)),
|
||
loadHead: () => rpc.threads.head({ groupId }, { signal: threadSnapshotSignal(abort.signal) }),
|
||
refresh: () => refreshGroupThread(groupId, threadSnapshotSignal(abort.signal)),
|
||
currentSnapshot: () => snapshotRef.current,
|
||
subscribe: (cursor) => rpc.threads.subscribe({ groupId, cursor }, { signal: abort.signal }),
|
||
applyEvent: (event) =>
|
||
applyThreadEvent(event, commitSnapshot, commitComputer, snapshotRef, computerRef),
|
||
onEvent: (event, initial) => {
|
||
const eventBot = botsRef.current.find((bot) => bot.id === event.botId);
|
||
notifyBrowserForEvent(
|
||
event,
|
||
initial.threadId,
|
||
initial.cursor,
|
||
true,
|
||
eventBot?.name ?? activeGroup.name,
|
||
true,
|
||
true,
|
||
);
|
||
if (event.type === "thread.message.created" && event.payload.role === "bot") {
|
||
readVisibleGroups.current.delete(groupId);
|
||
markVisibleGroupRead();
|
||
}
|
||
if (event.type === "group.created") {
|
||
void refreshBots().catch(() => undefined);
|
||
}
|
||
if (event.type === "group.updated") {
|
||
void refreshGroupThread(groupId).catch(() => undefined);
|
||
void refreshBots().catch(() => undefined);
|
||
}
|
||
if (event.type === "run.started" || isRunTerminalEvent(event)) {
|
||
void refreshBots().catch(() => undefined);
|
||
}
|
||
if (event.type === "thread.message.created") {
|
||
const blocks = (event.payload.blocks as Array<{ kind?: string }>) ?? [];
|
||
if (blocks.some((block) => block.kind === "project_created")) {
|
||
void refreshBots().catch(() => undefined);
|
||
}
|
||
}
|
||
if (event.type === "run.waiting_input" || event.type === "computer.takeover.requested") {
|
||
ooSound.play("need-you");
|
||
ooVocals.reactForEvent("need-you");
|
||
}
|
||
if (isRunTerminalEvent(event) || event.type === "run.waiting_input") {
|
||
// waiting_input: reconcile ask cards if a stale post-send refresh raced SSE.
|
||
void refreshGroupThread(groupId).catch(() => undefined);
|
||
}
|
||
},
|
||
});
|
||
return () => {
|
||
window.removeEventListener("focus", markVisibleGroupRead);
|
||
document.removeEventListener("visibilitychange", markVisibleGroupRead);
|
||
abort.abort();
|
||
};
|
||
}, [activeGroup?.id, groupId, notifyBrowserForEvent]);
|
||
|
||
const sidebarGroups = useMemo(() => {
|
||
const needle = query.toLowerCase();
|
||
const sidebarSpaces =
|
||
spaces.length > 0
|
||
? spaces.map((space) =>
|
||
space.id === bootstrapMe?.spaceId ? { ...space, bots, groups, botSections } : space,
|
||
)
|
||
: bootstrapMe
|
||
? [
|
||
{
|
||
id: bootstrapMe.spaceId,
|
||
name: "Personal",
|
||
isDefault: true,
|
||
hasContent: true,
|
||
canDelete: false,
|
||
bots,
|
||
groups,
|
||
botSections,
|
||
},
|
||
]
|
||
: [];
|
||
const showSpaceNames = sidebarSpaces.length > 1;
|
||
return sidebarSpaces.flatMap((space) => {
|
||
const visibleBots = space.bots.filter((bot) =>
|
||
`${bot.name} ${bot.title ?? ""} ${bot.preview ?? ""}`.toLowerCase().includes(needle),
|
||
);
|
||
const visibleGroups = space.groups.filter((group) =>
|
||
`${group.name} ${group.preview}`.toLowerCase().includes(needle),
|
||
);
|
||
const sections = groupBotsForSidebar(
|
||
[
|
||
...visibleBots.map((chat) => ({ kind: "bot" as const, chat })),
|
||
...visibleGroups.map((chat) => ({ kind: "group" as const, chat })),
|
||
].map((item) => ({ ...item, pinned: item.chat.pinned, sectionId: item.chat.sectionId })),
|
||
space.botSections,
|
||
).map((group, index) => ({
|
||
...group,
|
||
sectionId: group.key.startsWith("section:") ? group.key.slice("section:".length) : null,
|
||
key: showSpaceNames ? `space:${space.id}:${group.key}` : group.key,
|
||
title: showSpaceNames
|
||
? group.title
|
||
? `${space.name} · ${group.title}`
|
||
: space.name
|
||
: group.title,
|
||
showLock: showSpaceNames,
|
||
emptySpaceId: undefined as string | undefined,
|
||
/** A project without its main conversation yet offers to start it. */
|
||
startSpaceId: undefined as string | undefined,
|
||
spaceId: space.id,
|
||
spaceName: space.name,
|
||
canDeleteSpace: index === 0 && space.canDelete === true,
|
||
}));
|
||
if (sections.length > 0) return sections;
|
||
// Keep empty spaces selectable; chat clicks are the only switch control.
|
||
if (!showSpaceNames) return [];
|
||
if (needle && (space.bots.length > 0 || space.groups.length > 0)) return [];
|
||
return [
|
||
{
|
||
key: `space:${space.id}:empty`,
|
||
title: space.name,
|
||
bots: [],
|
||
sectionId: null,
|
||
showLock: true,
|
||
emptySpaceId: space.id,
|
||
startSpaceId: undefined,
|
||
spaceId: space.id,
|
||
spaceName: space.name,
|
||
canDeleteSpace: space.canDelete === true,
|
||
},
|
||
];
|
||
});
|
||
}, [bootstrapMe, botSections, bots, groups, spaces, query]);
|
||
|
||
const homeSpaceBots = useMemo(() => {
|
||
if (!homeSpaceId) return bots;
|
||
const home = spaces.find((space) => space.id === homeSpaceId);
|
||
return home?.bots ?? bots;
|
||
}, [bots, homeSpaceId, spaces]);
|
||
|
||
const conversationSidebarGroups = useMemo(() => {
|
||
if (!homeSpaceId) return [];
|
||
return sidebarGroups
|
||
.filter((group) => group.spaceId === homeSpaceId)
|
||
.map((group) => ({
|
||
...group,
|
||
title: stripSpaceTitlePrefix(group.title, group.spaceName),
|
||
showLock: false,
|
||
bots: group.bots
|
||
.filter((item) => item.kind === "bot")
|
||
.filter((item) => item.chat.kind !== "main"),
|
||
}))
|
||
.filter((group) => group.bots.length > 0);
|
||
}, [homeSpaceId, sidebarGroups]);
|
||
|
||
const mainConversationEntry = useMemo(() => {
|
||
if (!mainConversation) return null;
|
||
const inCurrentSpace = mainConversation.spaceId === bootstrapMe?.spaceId;
|
||
const space = spaces.find((item) => item.id === mainConversation.spaceId);
|
||
const bot = (inCurrentSpace ? bots : space?.bots)?.find(
|
||
(item) => item.id === mainConversation.botId,
|
||
);
|
||
const group = (inCurrentSpace ? groups : space?.groups)?.find(
|
||
(item) => item.id === mainConversation.groupId,
|
||
);
|
||
if (!bot || !group) return null;
|
||
return { ...mainConversation, bot, group };
|
||
}, [bootstrapMe?.spaceId, bots, groups, mainConversation, spaces]);
|
||
|
||
const projectSidebarGroups = useMemo(() => {
|
||
if (!homeSpaceId) return [];
|
||
const entries: typeof sidebarGroups = [];
|
||
const seenProjectSpaceIds = new Set<string>();
|
||
const seenProjectGroupChatIds = new Set<string>();
|
||
|
||
const dedupeSidebarBots = (bots: (typeof sidebarGroups)[number]["bots"]) => {
|
||
const seen = new Set<string>();
|
||
return bots.filter((item) => {
|
||
const key = `${item.kind}:${item.chat.id}`;
|
||
if (seen.has(key)) return false;
|
||
seen.add(key);
|
||
return true;
|
||
});
|
||
};
|
||
|
||
for (const space of spaces) {
|
||
if (space.id === homeSpaceId || seenProjectSpaceIds.has(space.id)) continue;
|
||
seenProjectSpaceIds.add(space.id);
|
||
const groupsInSpace = sidebarGroups.flatMap((group) =>
|
||
group.spaceId === space.id ? group.bots.filter((item) => item.kind === "group") : [],
|
||
);
|
||
// The project's main conversation, where the user talks to its main agent, comes first.
|
||
const projectBots = dedupeSidebarBots(groupsInSpace).sort(
|
||
(left, right) => Number(right.chat.kind === "main") - Number(left.chat.kind === "main"),
|
||
);
|
||
const hasMain = projectBots.some((item) => item.chat.kind === "main");
|
||
entries.push({
|
||
key:
|
||
projectBots.length > 0 ? `project-space:${space.id}` : `project-space:${space.id}:empty`,
|
||
title: space.name,
|
||
bots: projectBots,
|
||
sectionId: null,
|
||
showLock: true,
|
||
emptySpaceId: projectBots.length > 0 ? undefined : space.id,
|
||
startSpaceId: hasMain ? undefined : space.id,
|
||
spaceId: space.id,
|
||
spaceName: space.name,
|
||
canDeleteSpace: space.canDelete === true,
|
||
});
|
||
}
|
||
|
||
for (const group of sidebarGroups) {
|
||
if (group.spaceId !== homeSpaceId) continue;
|
||
const groupChats = dedupeSidebarBots(
|
||
group.bots.filter((item) => item.kind === "group" && item.chat.kind !== "main"),
|
||
).filter((item) => {
|
||
if (seenProjectGroupChatIds.has(item.chat.id)) return false;
|
||
seenProjectGroupChatIds.add(item.chat.id);
|
||
return true;
|
||
});
|
||
if (groupChats.length === 0) continue;
|
||
entries.push({
|
||
...group,
|
||
title: stripSpaceTitlePrefix(group.title, group.spaceName),
|
||
showLock: false,
|
||
emptySpaceId: undefined,
|
||
bots: groupChats,
|
||
});
|
||
}
|
||
|
||
// Apply persisted drag-to-reorder order if present
|
||
if (projectOrder.length > 0) {
|
||
const orderMap = new Map(entries.map((e, i) => [e.spaceId, i]));
|
||
entries.sort((a, b) => {
|
||
const aInOrder = orderMap.has(a.spaceId);
|
||
const bInOrder = orderMap.has(b.spaceId);
|
||
if (aInOrder && bInOrder)
|
||
return (orderMap.get(a.spaceId) ?? 0) - (orderMap.get(b.spaceId) ?? 0);
|
||
if (aInOrder) return -1;
|
||
if (bInOrder) return 1;
|
||
return 0;
|
||
});
|
||
}
|
||
|
||
return entries;
|
||
}, [homeSpaceId, sidebarGroups, spaces, projectOrder]);
|
||
|
||
// DnD handlers
|
||
const handleProjectDragEnd = useCallback(
|
||
(event: { active: { id: string | number }; over: { id: string | number } | null }) => {
|
||
if (!event.over) return;
|
||
const activeId = String(event.active.id);
|
||
const overId = String(event.over.id);
|
||
if (activeId === overId) return;
|
||
const sorted: any[] = [...projectSidebarGroups];
|
||
const activeIdx = sorted.findIndex((g) => g.spaceId === activeId);
|
||
const overIdx = sorted.findIndex((g) => g.spaceId === overId);
|
||
if (activeIdx === -1 || overIdx === -1) return;
|
||
const [moved] = sorted.splice(activeIdx, 1);
|
||
sorted.splice(overIdx, 0, moved);
|
||
const ids = sorted.map((g: any) => g.spaceId) as string[];
|
||
handleReorderProjects(ids);
|
||
},
|
||
[projectSidebarGroups, handleReorderProjects],
|
||
);
|
||
const handleProjectDragStart = useCallback((event: { active: { id: string | number } }) => {
|
||
setDraggedProjectId(String(event.active.id));
|
||
}, []);
|
||
|
||
const openSpaceChat = useCallback(
|
||
(spaceId: string, path: string) => {
|
||
setMobileSidebarOpen(false);
|
||
const previousSpaceId = selectedSpaceId();
|
||
// Persist the active space (including primary) so voice/RPC headers match the chat.
|
||
const selectionStored = selectSpace(spaceId);
|
||
if (!selectionStored) return;
|
||
const previousEffective = previousSpaceId ?? bootstrapMe?.spaceId;
|
||
const boundaryChanged = previousEffective !== spaceId;
|
||
// Soft-navigate within the same space; reload only when the auth boundary changes
|
||
// so bootstrapped bots/groups match the request header.
|
||
if (boundaryChanged) {
|
||
window.location.assign(path);
|
||
return;
|
||
}
|
||
navigate(path);
|
||
},
|
||
[bootstrapMe?.spaceId, navigate],
|
||
);
|
||
|
||
// A project is a conversation with its own main agent, created when first opened.
|
||
const startProject = useCallback(
|
||
async (spaceId: string) => {
|
||
setActiveNavPillar("projekte");
|
||
const main = await rpc.spaces.ensureMain({ spaceId });
|
||
if (spaceId === (selectedSpaceId() ?? bootstrapMe?.spaceId)) await refreshBots();
|
||
openSpaceChat(spaceId, `/app/g/${main.groupId}`);
|
||
},
|
||
[bootstrapMe?.spaceId, openSpaceChat, refreshBots],
|
||
);
|
||
|
||
const openMainConversation = useCallback(() => {
|
||
if (!mainConversation) return;
|
||
setActiveNavPillar("team");
|
||
openSpaceChat(mainConversation.spaceId, `/app/g/${mainConversation.groupId}`);
|
||
}, [mainConversation, openSpaceChat]);
|
||
|
||
const openConversationBot = useCallback(
|
||
(botId: string) => {
|
||
setConversationPickerOpen(false);
|
||
setMobileSidebarOpen(false);
|
||
if (botId === mainConversation?.botId) {
|
||
openMainConversation();
|
||
return;
|
||
}
|
||
setActiveNavPillar("team");
|
||
const bot =
|
||
homeSpaceBots.find((item) => item.id === botId) ?? bots.find((item) => item.id === botId);
|
||
if (bot) {
|
||
openSpaceChat(bot.spaceId, `/app/${botId}`);
|
||
return;
|
||
}
|
||
navigate(`/app/${botId}`);
|
||
},
|
||
[bots, homeSpaceBots, mainConversation?.botId, navigate, openMainConversation, openSpaceChat],
|
||
);
|
||
|
||
const flushBotOrder = useCallback(async () => {
|
||
if (savingBotOrderRef.current) return;
|
||
savingBotOrderRef.current = true;
|
||
try {
|
||
while (pendingBotOrderRef.current) {
|
||
const botIds = pendingBotOrderRef.current;
|
||
pendingBotOrderRef.current = null;
|
||
try {
|
||
await rpc.bots.reorder({ botIds });
|
||
} catch {
|
||
// Keep a newer order queued during this failed save; only roll back
|
||
// when nothing else is pending.
|
||
if (pendingBotOrderRef.current === null) {
|
||
await refreshBots(false, true).catch(() => undefined);
|
||
}
|
||
}
|
||
}
|
||
} finally {
|
||
savingBotOrderRef.current = false;
|
||
// A reorder may have arrived while saving=true and returned early.
|
||
if (pendingBotOrderRef.current) {
|
||
void flushBotOrder();
|
||
}
|
||
}
|
||
}, [refreshBots]);
|
||
const reorderRosterBot = useCallback(
|
||
(sourceId: string, targetId: string, groupBotIds: string[]) => {
|
||
if (!groupBotIds.includes(sourceId) || !groupBotIds.includes(targetId)) return;
|
||
const current = botsRef.current;
|
||
const reordered = reorderBotTo(current, sourceId, targetId);
|
||
if (reordered === current) return;
|
||
const next = [...reordered];
|
||
botOrderEpochRef.current += 1;
|
||
botsRef.current = next;
|
||
setBots(next);
|
||
pendingBotOrderRef.current = next.map((bot) => bot.id);
|
||
void flushBotOrder();
|
||
},
|
||
[flushBotOrder],
|
||
);
|
||
const toggleSidebarSection = useCallback(
|
||
(key: string) => {
|
||
setCollapsedSidebarSections((previous) => {
|
||
const next = new Set(previous);
|
||
if (next.has(key)) next.delete(key);
|
||
else next.add(key);
|
||
const storageKey = collapsedSidebarSectionsStorageKey(userId);
|
||
if (storageKey) {
|
||
try {
|
||
window.localStorage.setItem(storageKey, JSON.stringify([...next]));
|
||
} catch {
|
||
// Keep the UI usable when storage is unavailable.
|
||
}
|
||
}
|
||
return next;
|
||
});
|
||
},
|
||
[userId],
|
||
);
|
||
const spaceQuery = query.trim();
|
||
const showSpaceSearch = spaceQuery.length > 0;
|
||
|
||
useEffect(() => {
|
||
if (!showSpaceSearch) {
|
||
setSearchHits([]);
|
||
setSearchLoading(false);
|
||
return;
|
||
}
|
||
const abort = new AbortController();
|
||
const timer = window.setTimeout(() => {
|
||
setSearchLoading(true);
|
||
void rpc.search
|
||
.query({ q: spaceQuery })
|
||
.then((result) => {
|
||
if (!abort.signal.aborted) setSearchHits(result.hits);
|
||
})
|
||
.catch(() => {
|
||
if (!abort.signal.aborted) setSearchHits([]);
|
||
})
|
||
.finally(() => {
|
||
if (!abort.signal.aborted) setSearchLoading(false);
|
||
});
|
||
}, 200);
|
||
return () => {
|
||
abort.abort();
|
||
window.clearTimeout(timer);
|
||
};
|
||
}, [showSpaceSearch, Calendar, Settings, spaceQuery]);
|
||
|
||
async function jumpToSearchHit(hit: SearchHit) {
|
||
setQuery("");
|
||
setSearchHits([]);
|
||
const params = new URLSearchParams();
|
||
if (hit.messageId) params.set("m", hit.messageId);
|
||
if (hit.routineId) params.set("routine", hit.routineId);
|
||
navigate({
|
||
pathname: hit.groupId ? `/app/g/${hit.groupId}` : `/app/${hit.botId}`,
|
||
search: params.toString() ? `?${params.toString()}` : undefined,
|
||
});
|
||
}
|
||
|
||
async function jumpToMessage(target: { botId?: string; groupId?: string; messageId: string }) {
|
||
const threadTarget = searchHitThreadTarget(target);
|
||
const epoch = historyEpoch.current;
|
||
jumpGeneration.current += 1;
|
||
const jumpId = jumpGeneration.current;
|
||
const [snap, page] = await Promise.all([
|
||
rpc.threads.get(threadTarget),
|
||
rpc.threads.messages({ ...threadTarget, around: { messageId: target.messageId } }),
|
||
]);
|
||
// The epoch check drops a jump that raced a conversation clear (or a bot switch): applying
|
||
// the fetched page would pin deleted messages that every later refresh keeps restoring.
|
||
// jumpId drops an older jump that finished after a newer click.
|
||
if (epoch !== historyEpoch.current || jumpId !== jumpGeneration.current) return;
|
||
if (target.groupId && activeGroupId.current !== target.groupId) return;
|
||
if (target.botId && activeBotId.current !== target.botId) return;
|
||
const targetInPage = userVisibleMessages(page.messages, { includePeerReceipts: true }).some(
|
||
(message) => message.id === target.messageId,
|
||
);
|
||
expandedHistoryThread.current = targetInPage ? page.threadId : null;
|
||
pinnedAroundRef.current = targetInPage
|
||
? {
|
||
...threadTarget,
|
||
messageId: target.messageId,
|
||
threadId: page.threadId,
|
||
messages: page.messages,
|
||
olderCursor: page.olderCursor,
|
||
}
|
||
: null;
|
||
if (targetInPage) initiallyScrolledThread.current = page.threadId;
|
||
commitSnapshot({
|
||
...snap,
|
||
messages: targetInPage ? page.messages : snap.messages,
|
||
olderCursor: targetInPage ? page.olderCursor : snap.olderCursor,
|
||
});
|
||
if (threadTarget.botId) {
|
||
commitComputer(snap.computer ?? null);
|
||
// Don't block parent-scroll on routines metadata; a list failure must not abort the jump.
|
||
void rpc.routines
|
||
.list({ botId: threadTarget.botId })
|
||
.then((routines) => {
|
||
if (epoch !== historyEpoch.current || jumpId !== jumpGeneration.current) return;
|
||
if (activeBotId.current !== threadTarget.botId) return;
|
||
setRoutines(routines);
|
||
setRoutinesBotId(threadTarget.botId);
|
||
})
|
||
.catch(() => undefined);
|
||
} else {
|
||
commitComputer(null);
|
||
setRoutines([]);
|
||
setRoutinesBotId(null);
|
||
}
|
||
window.requestAnimationFrame(() => {
|
||
if (epoch !== historyEpoch.current || jumpId !== jumpGeneration.current) return;
|
||
if (!targetInPage) {
|
||
const element = messageScroll.current;
|
||
if (element) {
|
||
element.scrollTop = element.scrollHeight;
|
||
initiallyScrolledThread.current = page.threadId;
|
||
}
|
||
return;
|
||
}
|
||
document
|
||
.querySelector(`[data-message-id="${target.messageId}"]`)
|
||
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||
});
|
||
}
|
||
|
||
useEffect(() => {
|
||
const messageId = searchParams.get("m");
|
||
const routineId = searchParams.get("routine");
|
||
if (inGroup && groupId && messageId) {
|
||
void jumpToMessage({ groupId, messageId }).finally(() => {
|
||
// Keep expandedHistoryThread; only strip the jump URL so refresh does not remount.
|
||
const next = new URLSearchParams(searchParams);
|
||
next.delete("m");
|
||
setSearchParams(next, { replace: true });
|
||
});
|
||
return;
|
||
}
|
||
if (!active) return;
|
||
if (routineId && routinesBotId === active.id) {
|
||
const routine = routines.find((item) => item.id === routineId);
|
||
if (routine) {
|
||
setRoutineDraft(draftFromRoutine(routine));
|
||
setRoutineWebhookSecret(null);
|
||
setEditingRoutine(routine);
|
||
setPanel("routine");
|
||
} else {
|
||
setPanel("computer");
|
||
}
|
||
const next = new URLSearchParams(searchParams);
|
||
next.delete("routine");
|
||
setSearchParams(next, { replace: true });
|
||
}
|
||
if (messageId) {
|
||
void jumpToMessage({ botId: active.id, messageId }).finally(() => {
|
||
const next = new URLSearchParams(searchParams);
|
||
next.delete("m");
|
||
setSearchParams(next, { replace: true });
|
||
});
|
||
}
|
||
}, [active?.id, groupId, inGroup, routines, routinesBotId, searchParams, setSearchParams]);
|
||
|
||
useEffect(() => {
|
||
if (!inGroup || !groupId || !projectSessionKey || projectSessionKey === "live") return;
|
||
void jumpToMessage({ groupId, messageId: projectSessionKey });
|
||
}, [groupId, inGroup, projectSessionKey]);
|
||
|
||
useEffect(() => {
|
||
if (!botId && !groupId && emptyProjectSpace && initialBotsLoaded) {
|
||
setActiveNavPillar("projekte");
|
||
}
|
||
}, [botId, emptyProjectSpace, groupId, initialBotsLoaded]);
|
||
|
||
const activeSnapshot = inGroup
|
||
? snapshot?.groupId === groupId
|
||
? snapshot
|
||
: null
|
||
: snapshot?.botId === active?.id
|
||
? snapshot
|
||
: null;
|
||
const activeReplyTarget =
|
||
replyTarget && activeSnapshot?.messages.some((message) => message.id === replyTarget.id)
|
||
? replyTarget
|
||
: null;
|
||
const activeReplyQuote = activeReplyTarget ? replyQuote : null;
|
||
const clearReply = useCallback(() => {
|
||
setReplyTarget(null);
|
||
setReplyQuote(null);
|
||
}, []);
|
||
const currentRuns = useMemo(() => activeThreadRuns(activeSnapshot), [activeSnapshot]);
|
||
const answerableAskMessageId = latestAnswerableAskMessageId(activeSnapshot);
|
||
const workingRuns = useMemo(
|
||
() => currentRuns.filter((run) => isWorkingRunStatus(run.status)),
|
||
[currentRuns],
|
||
);
|
||
const transcriptRunning = workingRuns.length > 0;
|
||
const composerRunning = currentRuns.some((run) => isActive(run.status));
|
||
const runError = threadRunError(activeSnapshot, dismissedRunErrorIds);
|
||
const displayedRunError = !sendError ? runError : null;
|
||
const displayedRunErrorId = displayedRunError ? (activeSnapshot?.run?.id ?? null) : null;
|
||
const handleRunErrorPresented = useCallback((runId: string) => {
|
||
rememberSeenRunErrorId(runId);
|
||
}, []);
|
||
const transcriptMessages = useMemo(
|
||
() => userVisibleMessages(activeSnapshot?.messages ?? [], { includePeerReceipts: true }),
|
||
[activeSnapshot?.messages],
|
||
);
|
||
const transcriptArtifactTarget = useMemo<ArtifactTarget>(
|
||
() => (inGroup ? { groupId: groupId ?? "" } : { botId: active?.id ?? "" }),
|
||
[active?.id, groupId, inGroup],
|
||
);
|
||
const workspaceFocus = useMemo(
|
||
() =>
|
||
deriveWorkspaceFocus(
|
||
activeSnapshot ? { ...activeSnapshot, computer: inGroup ? null : computer } : null,
|
||
{ screenRunId, screenActive, screenMode: screenPaneMode(screenHabit) },
|
||
),
|
||
[activeSnapshot, computer, inGroup, screenActive, screenHabit, screenRunId],
|
||
);
|
||
const workspaceFocusRef = useRef<{ threadId: string; focus: WorkspaceFocus } | null>(null);
|
||
const autoPanelRef = useRef(autoPanel);
|
||
autoPanelRef.current = autoPanel;
|
||
const autoCloseTimer = useRef<number | undefined>(undefined);
|
||
useEffect(() => () => window.clearTimeout(autoCloseTimer.current), []);
|
||
// The pane follows the bot's work on focus edges only, so it never jumps while nothing changed.
|
||
useEffect(() => {
|
||
const threadId = activeSnapshot?.threadId;
|
||
const sameThread = Boolean(threadId) && workspaceFocusRef.current?.threadId === threadId;
|
||
// New work or another conversation cancels a pending close; an unrelated edge does not.
|
||
if (!sameThread || workspaceFocus.pane !== null) window.clearTimeout(autoCloseTimer.current);
|
||
if (!threadId) {
|
||
workspaceFocusRef.current = null;
|
||
return;
|
||
}
|
||
const previous = sameThread ? (workspaceFocusRef.current?.focus ?? null) : null;
|
||
workspaceFocusRef.current = { threadId, focus: workspaceFocus };
|
||
// Narrow windows keep the conversation full width; a pane would cover it.
|
||
if (!wideLayout) return;
|
||
const paneState: PaneState = {
|
||
open:
|
||
panel === "computer"
|
||
? "screen"
|
||
: panel === "artifact"
|
||
? "artifact"
|
||
: panel
|
||
? "other"
|
||
: null,
|
||
auto: panel !== null && panel === autoPanel,
|
||
};
|
||
const next = applyWorkspaceFocus(previous, workspaceFocus, paneState);
|
||
if (!next || next.open === "other") return;
|
||
if (next.open === null) {
|
||
const closing = autoPanelRef.current;
|
||
autoCloseTimer.current = window.setTimeout(() => {
|
||
if (closing === null || autoPanelRef.current !== closing) return;
|
||
setPanel(null);
|
||
setAutoPanel(null);
|
||
}, AUTO_SCREEN_CLOSE_DELAY_MS);
|
||
return;
|
||
}
|
||
if (next.open === "artifact") setArtifactPane(workspaceFocus.artifact);
|
||
const nextPanel: Panel = next.open === "screen" ? "computer" : next.open;
|
||
setPanel(nextPanel);
|
||
setAutoPanel(next.auto ? nextPanel : null);
|
||
}, [workspaceFocus.key, activeSnapshot?.threadId, wideLayout]);
|
||
const learnScreenPane = useCallback(
|
||
(choice: "opened" | "dismissed") => {
|
||
const botId = activeBotId.current;
|
||
if (!botId) return;
|
||
const next = recordScreenPaneChoice(screenHabit, choice);
|
||
setScreenHabit(next);
|
||
writeScreenPaneHabit(userId, botId, next);
|
||
},
|
||
[screenHabit, userId],
|
||
);
|
||
useEffect(() => {
|
||
if (autoPanel === null || panel === autoPanel) return;
|
||
// Closing the live screen while the bot still works on it teaches this bot's default.
|
||
const focus = workspaceFocusRef.current?.focus;
|
||
if (autoPanel === "computer" && panel === null && focus?.pane === "screen" && !focus.takeover) {
|
||
learnScreenPane("dismissed");
|
||
}
|
||
setAutoPanel(null);
|
||
}, [panel]);
|
||
const openArtifactBeside = useCallback((artifact: WorkspaceArtifact) => {
|
||
setArtifactPane(artifact);
|
||
setPanel("artifact");
|
||
}, []);
|
||
const transcriptMembers = activeSnapshot?.members ?? activeGroup?.members;
|
||
// Bots of every space, so a group message from a bot that is not a member still has a speaker.
|
||
const knownBots = useMemo(() => {
|
||
const byId = new Map<string, { name: string; color: string; status?: string }>();
|
||
for (const space of spaces) for (const bot of space.bots) byId.set(bot.id, bot);
|
||
for (const bot of bots) byId.set(bot.id, bot);
|
||
return byId;
|
||
}, [bots, spaces]);
|
||
const resolveTranscriptBot = useCallback(
|
||
(botId: string) => {
|
||
const bot = bots.find((candidate) => candidate.id === botId);
|
||
if (bot) return bot;
|
||
return transcriptMembers?.find((member) => member.botId === botId);
|
||
},
|
||
[bots, transcriptMembers],
|
||
);
|
||
const resolveSpeakerBot = useCallback(
|
||
(botId: string) => resolveTranscriptBot(botId) ?? knownBots.get(botId),
|
||
[knownBots, resolveTranscriptBot],
|
||
);
|
||
const enrichStageMember = useCallback(
|
||
(botId: string) => {
|
||
const bot = resolveTranscriptBot(botId);
|
||
if (!bot || !("title" in bot)) return undefined;
|
||
return {
|
||
title: bot.title,
|
||
description: bot.description,
|
||
};
|
||
},
|
||
[resolveTranscriptBot],
|
||
);
|
||
const workingBots: GroupAvatarMember[] = useMemo(() => {
|
||
if (!inGroup) {
|
||
return workingRuns.map((run) => {
|
||
const bot = resolveTranscriptBot(run.botId);
|
||
return {
|
||
botId: run.botId,
|
||
color: bot?.color ?? FALLBACK_BOT_COLOR,
|
||
name: bot?.name,
|
||
status: run.status,
|
||
};
|
||
});
|
||
}
|
||
return resolveWorkingGroupBots(activeSnapshot, enrichStageMember).map((member) => ({
|
||
botId: member.botId,
|
||
color: member.color,
|
||
name: member.name,
|
||
status: member.status,
|
||
}));
|
||
}, [activeSnapshot, enrichStageMember, inGroup, resolveTranscriptBot, workingRuns]);
|
||
|
||
const encounterMode = Boolean((active || activeGroup) && !showProjectTimeline && !callOpen);
|
||
const voiceEncounterActive = encounterMode || callOpen || voiceEntry;
|
||
const voiceCallBot = useMemo(() => {
|
||
if (active) return active;
|
||
if (!inGroup || !activeSnapshot) return undefined;
|
||
const speaker = resolveActiveGroupSpeaker(
|
||
activeSnapshot,
|
||
resolveWorkingGroupBots(activeSnapshot, enrichStageMember),
|
||
enrichStageMember,
|
||
);
|
||
if (!speaker) return undefined;
|
||
const bot = resolveTranscriptBot(speaker.botId);
|
||
if (!bot) return undefined;
|
||
const title = "title" in bot ? bot.title : (speaker.title ?? "");
|
||
const description = "description" in bot ? bot.description : (speaker.description ?? "");
|
||
return {
|
||
id: speaker.botId,
|
||
name: speaker.name,
|
||
title,
|
||
description,
|
||
color: speaker.color,
|
||
status: speaker.status ?? ("status" in bot ? bot.status : undefined),
|
||
};
|
||
}, [active, activeSnapshot, enrichStageMember, inGroup, resolveTranscriptBot]);
|
||
|
||
const voiceSetupPrompted = useRef(false);
|
||
useEffect(() => {
|
||
if (!voiceEntry || showProjectTimeline || callOpen) return;
|
||
if (!voiceCallBot) return;
|
||
if (voiceStatus?.ready) {
|
||
voiceSetupPrompted.current = false;
|
||
setCallOpen(true);
|
||
return;
|
||
}
|
||
if (voiceSetupPrompted.current) return;
|
||
voiceSetupPrompted.current = true;
|
||
openSettings("voice");
|
||
}, [voiceEntry, showProjectTimeline, callOpen, voiceCallBot, voiceStatus?.ready]);
|
||
|
||
const prevTranscriptRunning = useRef(false);
|
||
const prevRunErrorId = useRef<string | null>(null);
|
||
const seenHandoffKeys = useRef(new Set<string>());
|
||
const appSoundStarted = useRef(false);
|
||
const prevBotStatuses = useRef(new Map<string, string>());
|
||
|
||
useEffect(() => wireOoAudioVoiceDucking(), []);
|
||
|
||
useEffect(() => {
|
||
if (appSoundStarted.current) return;
|
||
appSoundStarted.current = true;
|
||
ooSound.play("app-start");
|
||
window.setTimeout(() => ooVoiceMark.maybePlayColdOpen(), 280);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
let timer: number | undefined;
|
||
|
||
const tick = async () => {
|
||
try {
|
||
const [active, recent] = await Promise.all([
|
||
rpc.runs.list({ filter: "active" }),
|
||
rpc.runs.list({ filter: "recent" }),
|
||
]);
|
||
if (cancelled) return;
|
||
const brief = buildHeuteBrief({
|
||
bots: botsRef.current,
|
||
groups,
|
||
activeRuns: active.runs,
|
||
recentRuns: recent.runs,
|
||
});
|
||
setHeuteActionCount(brief.actionCount);
|
||
} catch {
|
||
if (cancelled) return;
|
||
} finally {
|
||
if (!cancelled) {
|
||
timer = window.setTimeout(() => void tick(), 15_000);
|
||
}
|
||
}
|
||
};
|
||
|
||
void tick();
|
||
return () => {
|
||
cancelled = true;
|
||
if (timer !== undefined) window.clearTimeout(timer);
|
||
};
|
||
}, [groups]);
|
||
|
||
useEffect(() => {
|
||
for (const bot of bots) {
|
||
const prev = prevBotStatuses.current.get(bot.id);
|
||
if (
|
||
prev !== bot.status &&
|
||
(bot.status === "waiting_input" || bot.status === "waiting_takeover")
|
||
) {
|
||
ooSound.play("need-you");
|
||
ooVocals.reactForEvent("need-you");
|
||
}
|
||
prevBotStatuses.current.set(bot.id, bot.status);
|
||
}
|
||
}, [bots]);
|
||
|
||
useEffect(() => {
|
||
if (prevTranscriptRunning.current && !transcriptRunning && !callOpen) {
|
||
ooSound.play("success-resolve");
|
||
ooVocals.reactForEvent("success");
|
||
}
|
||
prevTranscriptRunning.current = transcriptRunning;
|
||
}, [transcriptRunning, callOpen]);
|
||
|
||
useEffect(() => {
|
||
const runId = displayedRunErrorId;
|
||
if (runId && runId !== prevRunErrorId.current) {
|
||
ooSound.play("attention");
|
||
}
|
||
prevRunErrorId.current = runId;
|
||
}, [displayedRunErrorId]);
|
||
|
||
const seenGroupSparkKeys = useRef(new Set<string>());
|
||
|
||
useEffect(() => {
|
||
for (const message of activeSnapshot?.messages ?? []) {
|
||
for (const block of message.blocks) {
|
||
if (block.kind === "handoff") {
|
||
const key = `${message.id}:handoff:${block.toBotId}`;
|
||
if (seenHandoffKeys.current.has(key)) continue;
|
||
seenHandoffKeys.current.add(key);
|
||
if (seenHandoffKeys.current.size > 80) {
|
||
const oldest = seenHandoffKeys.current.values().next().value;
|
||
if (oldest !== undefined) seenHandoffKeys.current.delete(oldest);
|
||
}
|
||
ooSound.play("handoff");
|
||
continue;
|
||
}
|
||
if (inGroup && block.kind === "bot_message_received") {
|
||
const key = `${message.id}:peer:${block.fromBotId}`;
|
||
if (seenGroupSparkKeys.current.has(key)) continue;
|
||
seenGroupSparkKeys.current.add(key);
|
||
if (seenGroupSparkKeys.current.size > 80) {
|
||
const oldest = seenGroupSparkKeys.current.values().next().value;
|
||
if (oldest !== undefined) seenGroupSparkKeys.current.delete(oldest);
|
||
}
|
||
ooSound.play("group-spark");
|
||
}
|
||
}
|
||
}
|
||
}, [activeSnapshot?.messages, inGroup]);
|
||
|
||
const resolveTranscriptMemberName = useCallback(
|
||
(botId: string | undefined) =>
|
||
memberName(transcriptMembers, botId) ??
|
||
(inGroup && botId ? knownBots.get(botId)?.name : undefined),
|
||
[inGroup, knownBots, transcriptMembers],
|
||
);
|
||
const replyTargetName = activeReplyTarget
|
||
? activeReplyTarget.role === "user"
|
||
? t`You`
|
||
: (resolveTranscriptMemberName(activeReplyTarget.botId) ?? active?.name ?? t`Bot`)
|
||
: undefined;
|
||
const composerMentionTargets = useMemo(() => {
|
||
const mentionBots = new Map(
|
||
bots.map((bot) => [bot.id, { id: bot.id, name: bot.name, color: bot.color }]),
|
||
);
|
||
// Group members stay mentionable when the sidebar bot list is still catching up
|
||
// after creating the group and remounting the shell.
|
||
if (inGroup) {
|
||
for (const member of [...(activeGroup?.members ?? []), ...(activeSnapshot?.members ?? [])]) {
|
||
if (!mentionBots.has(member.botId)) {
|
||
mentionBots.set(member.botId, {
|
||
id: member.botId,
|
||
name: member.name,
|
||
color: member.color,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
return buildComposerMentionOptions({
|
||
query: "",
|
||
includeEveryone: inGroup,
|
||
currentGroupId: groupId,
|
||
bots: [...mentionBots.values()],
|
||
groups: groups.map((group) => ({ id: group.id, name: group.name })),
|
||
routines: mentionRoutines.map((routine) => ({
|
||
id: routine.id,
|
||
name: routine.name,
|
||
crons: routine.crons,
|
||
botId: routine.botId,
|
||
botName: routine.botName,
|
||
})),
|
||
connectors: mentionConnectors,
|
||
});
|
||
}, [
|
||
activeGroup?.members,
|
||
activeSnapshot?.members,
|
||
bots,
|
||
groupId,
|
||
groups,
|
||
inGroup,
|
||
mentionConnectors,
|
||
mentionRoutines,
|
||
]);
|
||
const shellReady =
|
||
initialBotsLoaded &&
|
||
(inGroup
|
||
? Boolean(activeGroup && activeSnapshot)
|
||
: bots.length === 0 || Boolean(active && activeSnapshot));
|
||
const refreshThreadRef = useRef(refreshThread);
|
||
refreshThreadRef.current = refreshThread;
|
||
const refreshGroupThreadRef = useRef(refreshGroupThread);
|
||
refreshGroupThreadRef.current = refreshGroupThread;
|
||
const loadOlderMessagesRef = useRef(loadOlderMessages);
|
||
loadOlderMessagesRef.current = loadOlderMessages;
|
||
const jumpToMessageRef = useRef(jumpToMessage);
|
||
jumpToMessageRef.current = jumpToMessage;
|
||
|
||
const mentionBotsKey = useMemo(
|
||
() => bots.map((bot) => `${bot.id}:${bot.name}`).join(","),
|
||
[bots],
|
||
);
|
||
const botsForMentionsRef = useRef(bots);
|
||
botsForMentionsRef.current = bots;
|
||
|
||
useEffect(() => {
|
||
const bots = botsForMentionsRef.current;
|
||
if (!initialBotsLoaded || bots.length === 0) {
|
||
setMentionRoutines([]);
|
||
setMentionConnectors([]);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
const botNameById = new Map(bots.map((bot) => [bot.id, bot.name]));
|
||
void Promise.all(
|
||
bots.map((bot) =>
|
||
rpc.routines
|
||
.list({ botId: bot.id })
|
||
.then((rows) =>
|
||
rows.map((routine) => ({
|
||
...routine,
|
||
botName: botNameById.get(bot.id) ?? bot.name,
|
||
})),
|
||
)
|
||
.catch(() => [] as Array<Routine & { botName?: string }>),
|
||
),
|
||
).then((lists) => {
|
||
if (!cancelled) setMentionRoutines(lists.flat());
|
||
});
|
||
void Promise.all([
|
||
rpc.connections.list().catch(() => [] as Connection[]),
|
||
rpc.connections.catalog({}).catch(() => [] as ConnectionCatalogItem[]),
|
||
]).then(([connections, catalog]) => {
|
||
if (cancelled) return;
|
||
const connected = connections.filter((row) => row.status === "connected");
|
||
const options: Array<{
|
||
id: string;
|
||
name: string;
|
||
authStatus: "connected" | "needs_auth";
|
||
connectionId?: string;
|
||
}> = connected.map((row) => ({
|
||
id: row.id,
|
||
name: row.displayName,
|
||
authStatus: "connected" as const,
|
||
connectionId: row.id,
|
||
}));
|
||
for (const item of catalog) {
|
||
if (item.connected || item.noAuth) continue;
|
||
if (
|
||
connected.some(
|
||
(row) =>
|
||
row.provider.toLowerCase() === item.slug.toLowerCase() ||
|
||
row.displayName.toLowerCase() === item.name.toLowerCase(),
|
||
)
|
||
) {
|
||
continue;
|
||
}
|
||
options.push({
|
||
id: `catalog:${item.connectorId}:${item.slug}`,
|
||
name: item.name,
|
||
authStatus: "needs_auth",
|
||
});
|
||
}
|
||
setMentionConnectors(options);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [initialBotsLoaded, mentionBotsKey]);
|
||
|
||
useLayoutEffect(() => {
|
||
if (initialBotsLoaded) {
|
||
markOnce("rk:renderer:bots-committed");
|
||
markAfterPaint("rk:renderer:bots-painted");
|
||
}
|
||
if (active && snapshot?.botId === active.id) {
|
||
markOnce("rk:renderer:thread-committed");
|
||
markAfterPaint("rk:renderer:thread-painted");
|
||
}
|
||
if (shellReady) {
|
||
markOnce("rk:renderer:shell-ready");
|
||
markAfterPaint("rk:renderer:shell-painted");
|
||
}
|
||
}, [active, initialBotsLoaded, shellReady, snapshot?.botId]);
|
||
|
||
useLayoutEffect(() => {
|
||
const pin = pinnedAroundRef.current;
|
||
if (inGroup) {
|
||
if (!groupId || !snapshot || snapshot.groupId !== groupId) return;
|
||
if (initiallyScrolledThread.current === snapshot.threadId) return;
|
||
if (expandedHistoryThread.current === snapshot.threadId) return;
|
||
if (pin?.groupId === groupId) return;
|
||
} else {
|
||
if (!active || !snapshot || snapshot.botId !== active.id) return;
|
||
if (initiallyScrolledThread.current === snapshot.threadId) return;
|
||
if (expandedHistoryThread.current === snapshot.threadId) return;
|
||
if (pin?.botId === active.id) return;
|
||
}
|
||
const element = messageScroll.current;
|
||
if (!element) return;
|
||
element.scrollTop = element.scrollHeight;
|
||
initiallyScrolledThread.current = snapshot.threadId;
|
||
}, [active, groupId, inGroup, snapshot?.botId, snapshot?.groupId, snapshot?.threadId]);
|
||
|
||
const openBot = useCallback((id: string) => navigate(`/app/${id}`), [navigate]);
|
||
const openGroup = useCallback(
|
||
(id: string) => {
|
||
setActiveNavPillar("projekte");
|
||
navigate(`/app/g/${id}`);
|
||
},
|
||
[navigate],
|
||
);
|
||
const openProjectSession = useCallback(
|
||
(sourceMessageId: string) => {
|
||
const next = new URLSearchParams(searchParams);
|
||
next.set("s", sourceMessageId);
|
||
setSearchParams(next, { replace: true });
|
||
},
|
||
[searchParams, setSearchParams],
|
||
);
|
||
const openProjectChat = useCallback(() => {
|
||
const next = new URLSearchParams(searchParams);
|
||
next.set("s", "live");
|
||
setSearchParams(next, { replace: true });
|
||
}, [searchParams, setSearchParams]);
|
||
const openProjectGroupChat = useCallback(
|
||
(targetGroupId: string) => {
|
||
if (targetGroupId === groupId) {
|
||
openProjectChat();
|
||
return;
|
||
}
|
||
navigate(`/app/g/${targetGroupId}?s=live`);
|
||
},
|
||
[groupId, navigate, openProjectChat],
|
||
);
|
||
const clearVoiceEntryParam = useCallback(() => {
|
||
if (searchParams.get("voice") !== "1") return;
|
||
const next = new URLSearchParams(searchParams);
|
||
next.delete("voice");
|
||
setSearchParams(next, { replace: true });
|
||
}, [searchParams, setSearchParams]);
|
||
const openProjectVoiceFirst = useCallback(
|
||
(targetGroupId?: string) => {
|
||
const id = targetGroupId ?? groupId;
|
||
if (!id) return;
|
||
if (id === groupId) {
|
||
const next = new URLSearchParams(searchParams);
|
||
next.set("s", "live");
|
||
next.set("voice", "1");
|
||
setSearchParams(next, { replace: true });
|
||
return;
|
||
}
|
||
navigate(`/app/g/${id}?s=live&voice=1`);
|
||
},
|
||
[groupId, navigate, searchParams, setSearchParams],
|
||
);
|
||
const startVoiceEncounter = useCallback(() => {
|
||
if (voiceStatus?.ready) {
|
||
setCallOpen(true);
|
||
return;
|
||
}
|
||
openSettings("voice");
|
||
}, [voiceStatus?.ready]);
|
||
|
||
const closeProjectSession = useCallback(() => {
|
||
const next = new URLSearchParams(searchParams);
|
||
next.delete("s");
|
||
next.delete("voice");
|
||
setSearchParams(next, { replace: true });
|
||
}, [searchParams, setSearchParams]);
|
||
const closeVoiceStage = useCallback(() => {
|
||
setCallOpen(false);
|
||
clearVoiceEntryParam();
|
||
}, [clearVoiceEntryParam]);
|
||
const loadOlder = useCallback(() => loadOlderMessagesRef.current(), []);
|
||
const jumpToReplyMessage = useCallback((messageId: string) => {
|
||
const existing = document.querySelector(
|
||
`[data-message-id="${messageId.replace(/[^\w-]/g, (c) => `\\${c}`)}"]`,
|
||
);
|
||
if (existing) {
|
||
// Cancel any in-flight around-fetch so it cannot overwrite this scroll.
|
||
jumpGeneration.current += 1;
|
||
existing.scrollIntoView({ behavior: "smooth", block: "center" });
|
||
return;
|
||
}
|
||
const groupId = activeGroupId.current;
|
||
if (groupId) {
|
||
void jumpToMessageRef.current({ groupId, messageId });
|
||
return;
|
||
}
|
||
const botId = activeBotId.current;
|
||
if (botId) void jumpToMessageRef.current({ botId, messageId });
|
||
}, []);
|
||
const answerMessage = useCallback(async (message: ThreadMessage, text: string) => {
|
||
const botId = activeBotId.current;
|
||
const groupId = activeGroupId.current;
|
||
if (!botId && !groupId) return;
|
||
await rpc.threads.answer({
|
||
...(groupId ? { groupId } : { botId: botId! }),
|
||
runId: message.runId ?? "",
|
||
messageId: message.id,
|
||
answer: text,
|
||
});
|
||
if (groupId && activeGroupId.current === groupId) {
|
||
await refreshGroupThreadRef.current(groupId);
|
||
} else if (botId && activeBotId.current === botId) {
|
||
await refreshThreadRef.current(botId);
|
||
}
|
||
}, []);
|
||
const reactToMessage = useCallback(
|
||
async (message: ThreadMessage, reaction: MessageReaction) => {
|
||
const botId = activeBotId.current;
|
||
const groupId = activeGroupId.current;
|
||
if (!botId && !groupId) return;
|
||
try {
|
||
await rpc.threads.react({
|
||
...(groupId ? { groupId } : { botId: botId! }),
|
||
messageId: message.id,
|
||
reaction,
|
||
clientNonce: newClientNonce(),
|
||
});
|
||
} catch (error) {
|
||
const stillHere = groupId
|
||
? activeGroupId.current === groupId
|
||
: activeBotId.current === botId;
|
||
if (!stillHere) return;
|
||
setSendError(error instanceof Error ? error.message : t`Could not update reaction`);
|
||
}
|
||
},
|
||
[t],
|
||
);
|
||
const onAttachmentPick = useCallback(
|
||
async (files: FileList | null) => {
|
||
const threadKey = activeGroupId.current ?? activeBotId.current;
|
||
if (!threadKey || !files?.length) return;
|
||
const existing = attachmentsForThread(pendingAttachments, threadKey);
|
||
const next: PendingAttachment[] = [];
|
||
const skipped: string[] = [];
|
||
for (const file of Array.from(files)) {
|
||
if (existing.length + next.length >= ATTACHMENT_MAX_COUNT) {
|
||
skipped.push(t`${file.name} (max ${ATTACHMENT_MAX_COUNT} attachments)`);
|
||
continue;
|
||
}
|
||
if (file.size > ATTACHMENT_MAX_BYTES) {
|
||
skipped.push(t`${file.name} (over 10 MiB)`);
|
||
continue;
|
||
}
|
||
const mimeType = inferAttachmentMimeType(file.name, file.type);
|
||
if (!mimeType) {
|
||
skipped.push(file.name);
|
||
continue;
|
||
}
|
||
next.push({
|
||
id: `${file.name}-${file.size}-${file.lastModified}-${next.length}`,
|
||
threadKey,
|
||
file,
|
||
previewUrl: mimeType.startsWith("image/") ? URL.createObjectURL(file) : undefined,
|
||
});
|
||
}
|
||
if (next.length) setPendingAttachments((current) => [...current, ...next]);
|
||
setAttachmentNotice(skipped.length ? t`Skipped ${skipped.join(", ")}` : null);
|
||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||
},
|
||
[pendingAttachments, t],
|
||
);
|
||
const removeAttachment = useCallback((attachment: PendingAttachment) => {
|
||
revokePendingAttachmentPreviews([attachment]);
|
||
setPendingAttachments((current) => current.filter((item) => item.id !== attachment.id));
|
||
}, []);
|
||
const sendMessage = useCallback(
|
||
async (text: string, mentions: ComposerMention[] = []) => {
|
||
const initialBotTarget = activeBotId.current;
|
||
const initialGroupTarget = activeGroupId.current;
|
||
if (!initialBotTarget && !initialGroupTarget) return;
|
||
const originThreadKey = initialGroupTarget ?? initialBotTarget;
|
||
const attachments = attachmentsForThread(pendingAttachments, originThreadKey);
|
||
const plan = resolveComposerSendPlan({
|
||
text,
|
||
mentions,
|
||
hasAttachments: attachments.length > 0,
|
||
});
|
||
if (plan.isNoOp) return;
|
||
const reroutedToGroup = Boolean(
|
||
plan.rerouteGroupId && plan.rerouteGroupId !== initialGroupTarget,
|
||
);
|
||
const groupTarget = plan.rerouteGroupId ?? initialGroupTarget;
|
||
const botTarget = reroutedToGroup ? undefined : initialBotTarget;
|
||
if (
|
||
plan.shouldSend &&
|
||
(groupTarget || botsRef.current.find((bot) => bot.id === botTarget)?.notifyOnFinish)
|
||
) {
|
||
const permissionRequest = requestBrowserNotificationPermission();
|
||
if (permissionRequest) void permissionRequest.then(flushPendingBrowserNotifications);
|
||
}
|
||
const trimmed = plan.trimmed;
|
||
if (botTarget && !groupTarget && plan.shouldSend && attachments.length === 0) {
|
||
const voiceCmd = parseVoiceChatCommand(trimmed);
|
||
if (voiceCmd) {
|
||
setSendError(null);
|
||
setAttachmentNotice(null);
|
||
try {
|
||
const localVoiceToken = "local-kokoro";
|
||
if (voiceCmd.kind === "speak_off") {
|
||
await rpc.bots.update({ botId: botTarget, autoSpeak: false });
|
||
await refreshBots();
|
||
setAttachmentNotice(t`Speaking is off.`);
|
||
return;
|
||
}
|
||
if (voiceCmd.kind === "speak_on") {
|
||
await rpc.voice
|
||
.connect({ provider: "kokoro", apiKey: localVoiceToken })
|
||
.catch(() => undefined);
|
||
await rpc.bots.update({ botId: botTarget, autoSpeak: true });
|
||
await refreshBots();
|
||
void rpc.voice
|
||
.status()
|
||
.then(setVoiceStatus)
|
||
.catch(() => undefined);
|
||
setAttachmentNotice(t`Speaking is on.`);
|
||
return;
|
||
}
|
||
const voices = await rpc.voice.voices({});
|
||
const match = matchVoiceByName(voices, voiceCmd.voiceName);
|
||
if (!match) {
|
||
setSendError(t`Voice "${voiceCmd.voiceName}" not found.`);
|
||
return;
|
||
}
|
||
await rpc.voice
|
||
.connect({ provider: "kokoro", apiKey: localVoiceToken })
|
||
.catch(() => undefined);
|
||
await rpc.voice.setVoice({ voiceId: match.id, provider: "kokoro" });
|
||
await rpc.bots.update({ botId: botTarget, voiceId: match.id, autoSpeak: true });
|
||
await refreshBots();
|
||
void rpc.voice
|
||
.status()
|
||
.then(setVoiceStatus)
|
||
.catch(() => undefined);
|
||
setAttachmentNotice(t`Voice set to ${match.label}.`);
|
||
return;
|
||
} catch (error) {
|
||
setSendError(error instanceof Error ? error.message : t`Could not update voice`);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
sendsInFlight.current += 1;
|
||
setSending(true);
|
||
setSendError(null);
|
||
const dropDelayedSetup = () => {
|
||
// Only after successful engagement so a failed upload/send keeps the setup card.
|
||
if (initialBotTarget && focusPromptBotIdRef.current === initialBotTarget) {
|
||
cancelFocusPrompt();
|
||
}
|
||
};
|
||
try {
|
||
if (plan.shouldRunRoutines) {
|
||
const sendNonce = newClientNonce();
|
||
await Promise.all(
|
||
plan.routineIds.map((routineId) =>
|
||
rpc.routines.testRun({
|
||
routineId,
|
||
clientNonce: `routine-mention:${sendNonce}:${routineId}`,
|
||
}),
|
||
),
|
||
);
|
||
}
|
||
if (!plan.shouldSend) {
|
||
dropDelayedSetup();
|
||
clearReply();
|
||
revokePendingAttachmentPreviews(attachments);
|
||
setPendingAttachments((current) =>
|
||
current.filter((attachment) => attachment.threadKey !== originThreadKey),
|
||
);
|
||
setAttachmentNotice(null);
|
||
if (reroutedToGroup && groupTarget) {
|
||
navigate(`/app/g/${groupTarget}`);
|
||
return;
|
||
}
|
||
if (groupTarget && activeGroupId.current === groupTarget) {
|
||
await refreshGroupThreadRef.current(groupTarget);
|
||
} else if (botTarget && activeBotId.current === botTarget) {
|
||
await refreshThreadRef.current(botTarget);
|
||
}
|
||
return;
|
||
}
|
||
const artifactIds: string[] = [];
|
||
for (const pending of attachments) {
|
||
const mimeType = inferAttachmentMimeType(pending.file.name, pending.file.type);
|
||
if (!mimeType) {
|
||
throw new Error(t`Unsupported file type: ${pending.file.name}`);
|
||
}
|
||
const contentBase64 = await readFileAsBase64(pending.file);
|
||
const artifact = await rpc.artifacts.create(
|
||
groupTarget
|
||
? { groupId: groupTarget, name: pending.file.name, mimeType, contentBase64 }
|
||
: { botId: botTarget!, name: pending.file.name, mimeType, contentBase64 },
|
||
);
|
||
artifactIds.push(artifact.id);
|
||
}
|
||
const clientNonce = newClientNonce();
|
||
if (groupTarget) {
|
||
await rpc.threads.send({
|
||
groupId: groupTarget,
|
||
clientNonce,
|
||
text: trimmed || undefined,
|
||
mentions: plan.mentionPayload.length ? plan.mentionPayload : undefined,
|
||
artifactIds: artifactIds.length ? artifactIds : undefined,
|
||
replyToMessageId: reroutedToGroup ? undefined : activeReplyTarget?.id,
|
||
replyQuote: reroutedToGroup ? undefined : (activeReplyQuote ?? undefined),
|
||
});
|
||
} else if (botTarget) {
|
||
const sent = await rpc.threads.send({
|
||
botId: botTarget,
|
||
clientNonce,
|
||
text: trimmed || undefined,
|
||
mentions: plan.mentionPayload.length ? plan.mentionPayload : undefined,
|
||
artifactIds: artifactIds.length ? artifactIds : undefined,
|
||
replyToMessageId: activeReplyTarget?.id,
|
||
replyQuote: activeReplyQuote ?? undefined,
|
||
});
|
||
if (activeBotId.current === botTarget) {
|
||
updateSnapshot((current) =>
|
||
applyThreadSendReceipt(
|
||
current,
|
||
{
|
||
botId: botTarget,
|
||
runId: sent.runId,
|
||
taskId: sent.taskId,
|
||
},
|
||
terminalRunReceipts.current,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
ooSound.play("message-sent");
|
||
dropDelayedSetup();
|
||
clearReply();
|
||
revokePendingAttachmentPreviews(attachments);
|
||
setPendingAttachments((current) =>
|
||
current.filter((attachment) => attachment.threadKey !== originThreadKey),
|
||
);
|
||
// Refresh sidebar status even when a bot→group reroute navigates away below.
|
||
void refreshBots().catch(() => undefined);
|
||
if (reroutedToGroup && groupTarget) {
|
||
navigate(`/app/g/${groupTarget}`);
|
||
return;
|
||
}
|
||
if (groupTarget && activeGroupId.current === groupTarget) setAttachmentNotice(null);
|
||
if (botTarget && activeBotId.current === botTarget) setAttachmentNotice(null);
|
||
if (groupTarget) await refreshGroupThreadRef.current(groupTarget);
|
||
else if (botTarget) await refreshThreadRef.current(botTarget);
|
||
} catch (error) {
|
||
if (reroutedToGroup && groupTarget) {
|
||
setSendError(error instanceof Error ? error.message : t`Failed to send message`);
|
||
} else if (groupTarget && activeGroupId.current === groupTarget) {
|
||
setSendError(error instanceof Error ? error.message : t`Failed to send message`);
|
||
} else if (botTarget && activeBotId.current === botTarget) {
|
||
setSendError(error instanceof Error ? error.message : t`Failed to send message`);
|
||
}
|
||
} finally {
|
||
sendsInFlight.current = Math.max(0, sendsInFlight.current - 1);
|
||
if (sendsInFlight.current === 0) setSending(false);
|
||
}
|
||
},
|
||
[
|
||
activeReplyTarget?.id,
|
||
activeReplyQuote,
|
||
clearReply,
|
||
flushPendingBrowserNotifications,
|
||
navigate,
|
||
pendingAttachments,
|
||
refreshBots,
|
||
t,
|
||
],
|
||
);
|
||
const followUpMessage = useCallback(async (text: string) => {
|
||
const groupTarget = activeGroupId.current;
|
||
if (groupTarget) {
|
||
await rpc.threads.followUp({ groupId: groupTarget, text });
|
||
await refreshGroupThreadRef.current(groupTarget);
|
||
return;
|
||
}
|
||
const id = activeBotId.current;
|
||
if (!id) return;
|
||
await rpc.threads.followUp({ botId: id, text });
|
||
await refreshThreadRef.current(id);
|
||
}, []);
|
||
const stopRun = useCallback(async () => {
|
||
if (sending) return;
|
||
setSending(true);
|
||
try {
|
||
const botTarget = activeBotId.current;
|
||
const groupTarget = activeGroupId.current;
|
||
if (groupTarget) {
|
||
setSendError(null);
|
||
try {
|
||
await rpc.threads.stop({ groupId: groupTarget });
|
||
} catch (error) {
|
||
if (activeGroupId.current === groupTarget) {
|
||
setSendError(error instanceof Error ? error.message : t`Failed to stop`);
|
||
}
|
||
return;
|
||
}
|
||
// Stop has no terminal event; clear run UI before refresh races with in-flight gets.
|
||
if (activeGroupId.current === groupTarget) {
|
||
updateSnapshot((prev) =>
|
||
prev && prev.groupId === groupTarget ? clearActiveThreadRuns(prev) : prev,
|
||
);
|
||
}
|
||
await refreshGroupThreadRef.current(groupTarget).catch(() => undefined);
|
||
return;
|
||
}
|
||
if (!botTarget) return;
|
||
setSendError(null);
|
||
try {
|
||
await rpc.threads.stop({ botId: botTarget });
|
||
} catch (error) {
|
||
if (activeBotId.current === botTarget) {
|
||
setSendError(error instanceof Error ? error.message : t`Failed to stop`);
|
||
}
|
||
return;
|
||
}
|
||
// Stop does not emit a terminal thread event. Clear local run/busy immediately so a
|
||
// superseded in-flight refresh (older cursor) cannot leave Stop enabled / Take control
|
||
// blocked while the API is already idle.
|
||
if (activeBotId.current === botTarget) {
|
||
updateSnapshot((prev) =>
|
||
!prev || (prev.botId !== botTarget && prev.botId) ? prev : clearActiveThreadRuns(prev),
|
||
);
|
||
const currentComputer = computerRef.current;
|
||
if (currentComputer?.busyBotName) {
|
||
commitComputer({ ...currentComputer, busyBotName: null });
|
||
}
|
||
}
|
||
await refreshThreadRef.current(botTarget).catch(() => undefined);
|
||
} finally {
|
||
setSending(false);
|
||
}
|
||
}, [sending, t]);
|
||
const stopTeaching = useCallback(async () => {
|
||
const id = activeBotId.current;
|
||
if (!id || teachBusy) return;
|
||
const recording = taughtSkills.find(
|
||
(skill) => skill.status === "recording" && taughtSkillsBotId === id,
|
||
);
|
||
if (!recording) return;
|
||
setTeachBusy(true);
|
||
try {
|
||
await rpc.skills.stop({ skillId: recording.id });
|
||
await refreshThreadRef.current(id);
|
||
setComputerOpen(false);
|
||
} finally {
|
||
setTeachBusy(false);
|
||
}
|
||
}, [teachBusy, taughtSkills, taughtSkillsBotId]);
|
||
// Transcript and MessageView are memoized; these must stay referentially stable or every
|
||
// Shell state change re-renders the whole transcript.
|
||
const refreshActiveThread = useCallback(async () => {
|
||
const groupId = activeGroupId.current;
|
||
if (groupId) {
|
||
await refreshGroupThreadRef.current(groupId);
|
||
return;
|
||
}
|
||
const id = activeBotId.current;
|
||
if (!id) return;
|
||
await refreshThreadRef.current(id);
|
||
}, []);
|
||
// Teach chrome needs skills applied before this resolves — refreshThread only
|
||
// kicks skills.list off in the background, so Stop teaching would never mount
|
||
// if that background call failed or lagged behind local recovery.
|
||
const refreshActiveTeaching = useCallback(async () => {
|
||
const id = activeBotId.current;
|
||
if (!id) return;
|
||
await refreshThreadRef.current(id);
|
||
const skills = await rpc.skills.list({ botId: id });
|
||
if (activeBotId.current !== id) return;
|
||
setTaughtSkills(skills);
|
||
setTaughtSkillsBotId(id);
|
||
}, []);
|
||
const addSkillRoutine = useCallback((name: string, prompt: string) => {
|
||
setRoutineDraft({ ...emptyRoutineDraft(), name, prompt });
|
||
setRoutineWebhookSecret(null);
|
||
setEditingRoutine(null);
|
||
setPanel("routine");
|
||
}, []);
|
||
const speakingMessageIdRef = useRef(speakingMessageId);
|
||
speakingMessageIdRef.current = speakingMessageId;
|
||
const speakMessage = useCallback((message: ThreadMessage) => {
|
||
if (speakingMessageIdRef.current === message.id) {
|
||
speaker.stop();
|
||
return;
|
||
}
|
||
const text = speechFromBlocks(message.blocks);
|
||
const id = message.botId ?? activeBotId.current;
|
||
if (text && id) void speaker.speak(text, { botId: id, messageId: message.id });
|
||
}, []);
|
||
|
||
async function createGroup(input: { name: string; botIds: string[] }) {
|
||
const group = await rpc.groups.create(input);
|
||
setGroups((current) =>
|
||
current.some((item) => item.id === group.id) ? current : [group, ...current],
|
||
);
|
||
navigate(`/app/g/${group.id}`);
|
||
setPanel(null);
|
||
await refreshBots().catch(() => undefined);
|
||
}
|
||
|
||
function cancelFocusPrompt() {
|
||
focusPromptAbortRef.current?.abort();
|
||
focusPromptAbortRef.current = null;
|
||
focusPromptBotIdRef.current = null;
|
||
}
|
||
|
||
function setBotsSidebarCollapsedPref(collapsed: boolean) {
|
||
setBotsSidebarCollapsed(collapsed);
|
||
writeBotsSidebarCollapsed(userId, collapsed);
|
||
}
|
||
|
||
function openSettings(section: SettingsSection = "general") {
|
||
setSettingsSection(section);
|
||
setSettingsOpen(true);
|
||
}
|
||
|
||
async function createBot(input: {
|
||
name: string;
|
||
title: string;
|
||
description: string;
|
||
computerMode: ComputerMode;
|
||
}) {
|
||
const isFirstBot = botsRef.current.length === 0;
|
||
const bot = await rpc.bots.create({
|
||
...normalizeCreateBotProfile(input),
|
||
notifyOnFinish: true,
|
||
computerMode: input.computerMode,
|
||
});
|
||
setBots((current) =>
|
||
current.some((item) => item.id === bot.id) ? current : [bot, ...current],
|
||
);
|
||
navigate(`/app/${bot.id}`);
|
||
setPanel(null);
|
||
// Register cancellation before awaiting start so leaving the bot during
|
||
// startup cannot miss the abort and still schedule a late focus card.
|
||
cancelFocusPrompt();
|
||
const controller = new AbortController();
|
||
focusPromptAbortRef.current = controller;
|
||
focusPromptBotIdRef.current = bot.id;
|
||
const started = await rpc.onboarding
|
||
.start({ botId: bot.id })
|
||
.then(() => true)
|
||
.catch(() => false);
|
||
if (!started || controller.signal.aborted || focusPromptBotIdRef.current !== bot.id) {
|
||
if (focusPromptAbortRef.current === controller) {
|
||
focusPromptAbortRef.current = null;
|
||
focusPromptBotIdRef.current = null;
|
||
}
|
||
await refreshBots().catch(() => undefined);
|
||
return;
|
||
}
|
||
void scheduleFocusPrompt({
|
||
immediate: isFirstBot,
|
||
signal: controller.signal,
|
||
prompt: async () => {
|
||
if (focusPromptBotIdRef.current !== bot.id || activeBotId.current !== bot.id) return;
|
||
await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined);
|
||
},
|
||
}).finally(() => {
|
||
if (focusPromptAbortRef.current === controller) {
|
||
focusPromptAbortRef.current = null;
|
||
focusPromptBotIdRef.current = null;
|
||
}
|
||
});
|
||
await refreshBots().catch(() => undefined);
|
||
}
|
||
|
||
async function bootComputer({
|
||
takeControl,
|
||
overlay,
|
||
force = false,
|
||
}: {
|
||
takeControl: boolean;
|
||
overlay: boolean;
|
||
force?: boolean;
|
||
}) {
|
||
if (!active) return;
|
||
const needsBoot = force || computer?.state !== "running" || !screenUrl;
|
||
if (overlay && needsBoot) setBooting(true);
|
||
setComputerError(null);
|
||
setComputerErrorFromScreen(false);
|
||
try {
|
||
if (needsBoot) await rpc.computer.boot({ botId: active.id });
|
||
if (takeControl) await rpc.computer.takeover({ botId: active.id });
|
||
await refreshThread(active.id);
|
||
} catch (error) {
|
||
setComputerError(error instanceof Error ? error.message : t`Could not take control`);
|
||
setComputerErrorFromScreen(false);
|
||
throw error;
|
||
} finally {
|
||
setBooting(false);
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (panel !== "computer") {
|
||
autoBooted.current = null;
|
||
return;
|
||
}
|
||
if (!active) return;
|
||
const botId = active.id;
|
||
let cancelled = false;
|
||
void (async () => {
|
||
// Refresh from the server first. A stale SSE "booting" snapshot used to
|
||
// skip this effect, so an RPC takeover never showed "You have control".
|
||
const snap = await refreshThread(botId).catch(() => null);
|
||
if (cancelled || activeBotId.current !== botId) return;
|
||
const state = snap?.computer?.state;
|
||
const screen = state === "running" ? await refreshComputerScreen(botId) : null;
|
||
if (cancelled || activeBotId.current !== botId) return;
|
||
const action = computerPanelAutoBoot(state, screen);
|
||
if (action === "wait") {
|
||
if (state === "running") autoBooted.current = botId;
|
||
return;
|
||
}
|
||
if (action === "boot" && autoBooted.current === botId) return;
|
||
autoBooted.current = botId;
|
||
if (!computerPanelAutoUsesBoot(action)) return;
|
||
await bootComputer({
|
||
takeControl: false,
|
||
overlay: action === "boot",
|
||
force: true,
|
||
}).catch(() => undefined);
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [panel, active?.id]);
|
||
|
||
useEffect(() => {
|
||
setComputerOpen(false);
|
||
setComputerError(null);
|
||
setComputerErrorFromScreen(false);
|
||
setScreenRunId(null);
|
||
setScreenActive(false);
|
||
window.clearTimeout(screenActiveTimer.current);
|
||
}, [active?.id]);
|
||
|
||
useEffect(() => {
|
||
setScreenHabit(readScreenPaneHabit(userId, active?.id));
|
||
}, [active?.id, userId]);
|
||
|
||
// A presented file belongs to its conversation, and a new conversation starts without auto panes.
|
||
useEffect(() => {
|
||
setPanel((current) => (current === "artifact" ? null : current));
|
||
setArtifactPane(null);
|
||
setAutoPanel(null);
|
||
}, [active?.id, groupId]);
|
||
|
||
useEffect(() => {
|
||
if (focusPromptBotIdRef.current && focusPromptBotIdRef.current !== active?.id) {
|
||
cancelFocusPrompt();
|
||
}
|
||
}, [active?.id]);
|
||
|
||
useEffect(() => () => cancelFocusPrompt(), []);
|
||
|
||
useEffect(() => {
|
||
if (!computer?.busyBotName) {
|
||
setComputerError(null);
|
||
setComputerErrorFromScreen(false);
|
||
}
|
||
}, [computer?.busyBotName]);
|
||
|
||
useEffect(() => {
|
||
if (panel !== "routine") {
|
||
routineSaveRequest.current += 1;
|
||
setRoutineError(null);
|
||
}
|
||
}, [panel]);
|
||
|
||
// The routine panel copies a routine's data into local draft state at click time
|
||
// rather than deriving it from `active`, so it goes stale across a bot switch —
|
||
// without this, Save on bot B could silently update bot A's routine.
|
||
useEffect(() => {
|
||
setEditingRoutine(null);
|
||
setDeleteRoutineTarget(null);
|
||
setPanel((current) => (current === "routine" ? null : current));
|
||
}, [active?.id]);
|
||
|
||
useEffect(() => {
|
||
const threadKey = inGroup ? groupId : active?.id;
|
||
setPendingAttachments((current) => {
|
||
const stale = current.filter((attachment) => attachment.threadKey !== threadKey);
|
||
revokePendingAttachmentPreviews(stale);
|
||
return attachmentsForThread(current, threadKey);
|
||
});
|
||
clearReply();
|
||
setAttachmentNotice(null);
|
||
setSendError(null);
|
||
}, [active?.id, clearReply, groupId, inGroup]);
|
||
|
||
useEffect(() => {
|
||
if (!computerOpen) return;
|
||
function onKey(event: KeyboardEvent) {
|
||
if (event.key === "Escape") setComputerOpen(false);
|
||
}
|
||
window.addEventListener("keydown", onKey);
|
||
return () => window.removeEventListener("keydown", onKey);
|
||
}, [computerOpen]);
|
||
|
||
useEffect(() => {
|
||
function onKey(event: KeyboardEvent) {
|
||
if (!isCommandPaletteHotkey(event)) return;
|
||
event.preventDefault();
|
||
setCommandPaletteOpen((open) => !open);
|
||
}
|
||
window.addEventListener("keydown", onKey);
|
||
return () => window.removeEventListener("keydown", onKey);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if ((panel !== "computer" && !computerOpen) || !active || computer?.state !== "running") return;
|
||
const ping = () => void rpc.computer.heartbeat({ botId: active.id }).catch(() => undefined);
|
||
ping();
|
||
const timer = window.setInterval(ping, 60_000);
|
||
return () => window.clearInterval(timer);
|
||
}, [panel, computerOpen, active?.id, computer?.state]);
|
||
|
||
async function openComputer() {
|
||
if (!active) return;
|
||
const needsTakeover = !userHoldsComputerControl(computer, active.id);
|
||
const blocked = computerTakeoverBlocked(computer, snapshot?.run?.status);
|
||
try {
|
||
await bootComputer({
|
||
takeControl: needsTakeover && !blocked,
|
||
overlay: (needsTakeover && !blocked) || computer?.state !== "running",
|
||
force: computer?.state !== "running",
|
||
});
|
||
setComputerOpen(true);
|
||
} catch {
|
||
// computerError already set in bootComputer
|
||
}
|
||
}
|
||
|
||
const releaseComputer = useCallback(
|
||
async (reason?: ComputerReleaseReason) => {
|
||
const botId = activeBotId.current;
|
||
if (!botId) return;
|
||
try {
|
||
await rpc.computer.release({ botId, reason });
|
||
if (activeBotId.current !== botId) return;
|
||
setComputerOpen(false);
|
||
await refreshThreadRef.current(botId).catch(() => undefined);
|
||
} catch {
|
||
if (activeBotId.current !== botId) return;
|
||
setComputerError(t`Could not continue`);
|
||
setComputerErrorFromScreen(false);
|
||
}
|
||
},
|
||
[t],
|
||
);
|
||
|
||
function dismissComposerError() {
|
||
// The strip shows one message at a time, so only dismiss the run failure when it is the
|
||
// one on screen; otherwise a live run would be silenced before it has even failed.
|
||
const failedRunId = displayedRunErrorId;
|
||
setSendError(null);
|
||
if (failedRunId) {
|
||
rememberSeenRunErrorId(failedRunId);
|
||
setDismissedRunErrorIds((current) => new Set(current).add(failedRunId));
|
||
}
|
||
}
|
||
|
||
const embeddedScreenUrl = embeddableScreenUrl(screenUrl);
|
||
// Only what the work opened gets the wide pane; settings, routines and a hand-opened screen keep
|
||
// one width so switching between them never jumps.
|
||
const workspacePaneOpen = panel === "artifact" || (panel === "computer" && autoPanel === panel);
|
||
const hasControl = userHoldsComputerControl(computer, active?.id);
|
||
const hideScreenLoadError = computerErrorFromScreen && Boolean(embeddedScreenUrl);
|
||
const computerScreenError =
|
||
computerError && !hideScreenLoadError ? (
|
||
<div role="alert" className="flex flex-col items-center gap-3 px-6 text-center text-sm">
|
||
<p className="text-destructive">{computerError}</p>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => active && void refreshComputerScreen(active.id)}
|
||
>
|
||
<Trans>Retry screen</Trans>
|
||
</Button>
|
||
</div>
|
||
) : null;
|
||
|
||
const userName = session.data?.user.name ?? t`You`;
|
||
const initials = userName
|
||
.split(" ")
|
||
.map((p) => p[0])
|
||
.join("")
|
||
.slice(0, 2)
|
||
.toUpperCase();
|
||
|
||
const shell = (
|
||
<div
|
||
data-testid="shell-root"
|
||
data-ready={shellReady}
|
||
className="relative flex h-full min-w-0 overflow-hidden bg-background text-foreground/90 p-0 md:p-3 gap-0 md:gap-3"
|
||
onTouchStartCapture={(event) => {
|
||
if (
|
||
mobileSidebarOpen ||
|
||
event.touches.length !== 1 ||
|
||
window.matchMedia("(min-width: 768px)").matches
|
||
) {
|
||
mobileSidebarSwipeRef.current = null;
|
||
return;
|
||
}
|
||
const touch = event.touches[0];
|
||
if (!touch) return;
|
||
const rtl = document.documentElement.getAttribute("dir") === "rtl";
|
||
const startsAtEdge = rtl
|
||
? touch.clientX >= window.innerWidth - MOBILE_SIDEBAR_SWIPE_EDGE_PX
|
||
: touch.clientX <= MOBILE_SIDEBAR_SWIPE_EDGE_PX;
|
||
mobileSidebarSwipeRef.current = startsAtEdge
|
||
? { startX: touch.clientX, startY: touch.clientY }
|
||
: null;
|
||
}}
|
||
onTouchEndCapture={(event) => {
|
||
const swipe = mobileSidebarSwipeRef.current;
|
||
mobileSidebarSwipeRef.current = null;
|
||
const touch = event.changedTouches[0];
|
||
if (
|
||
!swipe ||
|
||
!touch ||
|
||
mobileSidebarOpen ||
|
||
window.matchMedia("(min-width: 768px)").matches
|
||
) {
|
||
return;
|
||
}
|
||
const rtl = document.documentElement.getAttribute("dir") === "rtl";
|
||
const horizontal = rtl ? swipe.startX - touch.clientX : touch.clientX - swipe.startX;
|
||
const vertical = Math.abs(touch.clientY - swipe.startY);
|
||
if (horizontal >= MOBILE_SIDEBAR_SWIPE_DISTANCE_PX && horizontal > vertical * 1.25) {
|
||
setMobileSidebarOpen(true);
|
||
}
|
||
}}
|
||
onTouchCancelCapture={() => {
|
||
mobileSidebarSwipeRef.current = null;
|
||
}}
|
||
>
|
||
<ComputerUpdateProgress
|
||
onCompleted={() => {
|
||
if (active) void refreshThread(active.id);
|
||
}}
|
||
/>
|
||
{bootstrapMe !== undefined ? (
|
||
<HostComputerPrompt initialMe={bootstrapMe ?? undefined} />
|
||
) : null}
|
||
{mobileSidebarOpen ? (
|
||
<button
|
||
type="button"
|
||
aria-label={t`Close navigation`}
|
||
onClick={() => setMobileSidebarOpen(false)}
|
||
className="absolute inset-y-0 end-0 start-[min(calc(100%-48px),316px)] z-30 bg-overlay md:hidden"
|
||
/>
|
||
) : null}
|
||
{!mobileSidebarOpen ? (
|
||
<div
|
||
data-testid="mobile-sidebar-swipe-edge"
|
||
aria-hidden="true"
|
||
className="absolute bottom-20 start-0 top-16 z-20 w-8 touch-none md:hidden"
|
||
/>
|
||
) : null}
|
||
<aside
|
||
data-testid="bots-sidebar"
|
||
data-collapsed={botsSidebarCollapsed ? "true" : "false"}
|
||
inert={botsSidebarCollapsed && !mobileSidebarOpen ? true : undefined}
|
||
style={{
|
||
width: mobileSidebarOpen ? undefined : botsSidebarCollapsed ? 0 : leftSidebarWidth,
|
||
maxWidth: mobileSidebarOpen ? 316 : undefined,
|
||
}}
|
||
className={`absolute inset-y-0 start-0 z-40 flex w-[calc(100%-48px)] shrink-0 flex-col md:rounded-2xl md:border md:border-sidebar-border md:shadow-2xl bg-sidebar md:static md:z-auto md:translate-x-0 ${
|
||
mobileSidebarOpen ? "translate-x-0" : "-translate-x-full rtl:translate-x-full"
|
||
} ${
|
||
botsSidebarCollapsed
|
||
? "md:w-0 md:max-w-0 md:overflow-hidden md:border-e-0 md:opacity-0 md:pointer-events-none"
|
||
: ""
|
||
} ${!isResizingLeft ? "transition-[transform,width,opacity] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]" : ""}`}
|
||
>
|
||
<div
|
||
onPointerDown={startResizingLeft}
|
||
className="absolute -right-2 top-0 bottom-0 w-4 cursor-col-resize z-50 hidden md:block"
|
||
/>
|
||
<div className="app-drag flex items-center justify-between px-4 lg:px-6 2xl:px-8 pb-3 pt-4">
|
||
<WindowChrome />
|
||
<div className="flex-1 px-1">
|
||
<Wordmark />
|
||
</div>
|
||
<div className="relative flex items-center gap-2.5">
|
||
<button
|
||
type="button"
|
||
aria-label={t`Activity`}
|
||
aria-pressed={activityMode}
|
||
title={t`Activity`}
|
||
data-activity-mode={activityMode ? "on" : "off"}
|
||
onClick={toggleActivityMode}
|
||
className={`app-no-drag flex size-control-sm items-center justify-center rounded-full ${
|
||
activityMode
|
||
? "bg-primary text-primary-foreground"
|
||
: "rk-sidebar-muted hover:text-foreground/75"
|
||
}`}
|
||
>
|
||
<Bell
|
||
strokeWidth={1.8}
|
||
fill={activityMode ? "currentColor" : "none"}
|
||
aria-hidden="true"
|
||
className="size-icon-md"
|
||
/>
|
||
</button>
|
||
|
||
<Popover open={createMenuOpen} onOpenChange={setCreateMenuOpen}>
|
||
<PopoverTrigger
|
||
className="app-no-drag text-[21px] rk-sidebar-muted hover:text-foreground/75"
|
||
title={t`Create`}
|
||
data-testid="create-menu-trigger"
|
||
>
|
||
+
|
||
</PopoverTrigger>
|
||
{/* Unmount with the state change so the panel it opens never coexists with the menu. */}
|
||
{createMenuOpen ? (
|
||
<PopoverContent
|
||
align="end"
|
||
className="app-no-drag w-auto gap-0 overflow-hidden p-0 data-closed:animate-none"
|
||
>
|
||
<BotCreatePicker
|
||
bots={bots}
|
||
onCreateBot={() => {
|
||
setCreateMenuOpen(false);
|
||
setMobileSidebarOpen(false);
|
||
setPanel("create");
|
||
}}
|
||
onOpenBot={(id) => {
|
||
setCreateMenuOpen(false);
|
||
setMobileSidebarOpen(false);
|
||
navigate(`/app/${id}`);
|
||
}}
|
||
onCreateGroup={() => {
|
||
setCreateMenuOpen(false);
|
||
setMobileSidebarOpen(false);
|
||
setPanel("create-group");
|
||
}}
|
||
onCreateSpace={() => {
|
||
setCreateMenuOpen(false);
|
||
setMobileSidebarOpen(false);
|
||
openNewSpaceDialog("space");
|
||
}}
|
||
onShowGroupInfo={() => {
|
||
setCreateMenuOpen(false);
|
||
setMobileSidebarOpen(false);
|
||
setPickerInfoTopic("group");
|
||
}}
|
||
onShowSpaceInfo={() => {
|
||
setCreateMenuOpen(false);
|
||
setMobileSidebarOpen(false);
|
||
setPickerInfoTopic("space");
|
||
}}
|
||
/>
|
||
</PopoverContent>
|
||
) : null}
|
||
</Popover>
|
||
</div>
|
||
</div>
|
||
|
||
<InputGroup
|
||
data-testid="sidebar-search"
|
||
className="mx-3 lg:mx-5 2xl:mx-7 mb-3 w-auto rounded-xl bg-card dark:bg-input border border-border text-muted-foreground focus-within:border-ring"
|
||
>
|
||
<InputGroupAddon>
|
||
<Search strokeWidth={1.8} aria-hidden="true" className="size-icon-md" />
|
||
</InputGroupAddon>
|
||
<InputGroupInput
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
placeholder={t`Search`}
|
||
autoComplete="off"
|
||
name="sidebar-search"
|
||
/>
|
||
</InputGroup>
|
||
<div className="rk-scroll flex flex-1 flex-col gap-0.5 overflow-y-auto px-3 lg:px-5 2xl:px-7 pb-2.5">
|
||
{showSpaceSearch ? (
|
||
<SpaceSearchResults
|
||
hits={searchHits}
|
||
loading={searchLoading}
|
||
onSelect={(hit) => void jumpToSearchHit(hit)}
|
||
/>
|
||
) : (
|
||
<>
|
||
{activityMode ? (
|
||
<ActivityList
|
||
onOpenRun={(run) => {
|
||
setMobileSidebarOpen(false);
|
||
if (run.groupId) navigate(`/app/g/${run.groupId}`);
|
||
else navigate(`/app/${run.botId}`);
|
||
}}
|
||
/>
|
||
) : null}
|
||
|
||
<div className="mb-3">
|
||
<button
|
||
type="button"
|
||
aria-label={
|
||
heuteActionCount > 0 ? t`Today, ${heuteActionCount} need you` : t`Today`
|
||
}
|
||
onClick={() => {
|
||
ooSound.play("interact");
|
||
setActiveNavPillar("heute");
|
||
setMobileSidebarOpen(false);
|
||
}}
|
||
className={rosterRowClass(activeNavPillar === "heute")}
|
||
>
|
||
<div className="relative grid size-control-lg shrink-0 place-items-center rounded-full bg-muted text-muted-foreground">
|
||
<Calendar strokeWidth={2.2} className="size-icon-md" />
|
||
{heuteActionCount > 0 ? (
|
||
<span
|
||
aria-hidden="true"
|
||
className="absolute -right-0.5 -top-0.5 grid min-w-[18px] place-items-center rounded-full bg-warning px-1 text-ui-label font-bold leading-none text-background"
|
||
>
|
||
{heuteActionCount > 9 ? "9+" : heuteActionCount}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<div className="font-semibold text-foreground/90 tracking-tight text-ui-body">
|
||
<Trans>Today</Trans>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
<div className="sidebar-unterhaltungen">
|
||
<div className="flex items-center justify-between pt-4 pb-2 px-2.5">
|
||
<div className="rk-shell-section-label">{t`Talk to your Oos`}</div>
|
||
<div className="flex items-center">
|
||
<Popover open={conversationPickerOpen} onOpenChange={setConversationPickerOpen}>
|
||
<PopoverTrigger
|
||
type="button"
|
||
aria-label={t`New conversation`}
|
||
data-testid="conversation-picker-trigger"
|
||
className="rk-sidebar-muted hover:text-foreground/90 transition-colors"
|
||
>
|
||
<Plus strokeWidth={2.5} className="size-icon-md" />
|
||
</PopoverTrigger>
|
||
{conversationPickerOpen ? (
|
||
<PopoverContent
|
||
align="end"
|
||
className="w-auto gap-0 overflow-hidden p-0 data-closed:animate-none"
|
||
>
|
||
<ConversationBotPicker
|
||
bots={bots}
|
||
onCreateBot={() => {
|
||
setConversationPickerOpen(false);
|
||
setPanel("create");
|
||
}}
|
||
onOpenBot={openConversationBot}
|
||
/>
|
||
</PopoverContent>
|
||
) : null}
|
||
</Popover>
|
||
</div>
|
||
</div>
|
||
{mainConversationEntry ? (
|
||
<div data-sidebar-group="main">
|
||
<MainConversationRow
|
||
name={mainConversationEntry.group.name}
|
||
botId={mainConversationEntry.bot.id}
|
||
color={mainConversationEntry.bot.color}
|
||
status={mainConversationEntry.bot.status}
|
||
unread={mainConversationEntry.group.unread}
|
||
time={formatRosterTime(mainConversationEntry.group.updatedAt)}
|
||
subtitle={rosterActivitySubtitle(
|
||
mainConversationEntry.bot.status,
|
||
cleanRosterPreview(localizedPreview(mainConversationEntry.group)),
|
||
)}
|
||
active={inGroup && groupId === mainConversationEntry.groupId}
|
||
onOpen={openMainConversation}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
{conversationSidebarGroups.length === 0 && !mainConversationEntry ? (
|
||
<div className="p-4 text-center text-ui-label rk-sidebar-muted">
|
||
<p>{t`No conversations yet.`}</p>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="mt-2 text-ui-label"
|
||
onClick={() => setConversationPickerOpen(true)}
|
||
>
|
||
<Plus className="mr-1 size-icon-sm" />
|
||
{t`New conversation`}
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
{conversationSidebarGroups.map((group) => {
|
||
const collapsed = Boolean(group.title) && collapsedSidebarSections.has(group.key);
|
||
const groupBotIds = group.bots.flatMap((item) =>
|
||
item.kind === "bot" ? [item.chat.id] : [],
|
||
);
|
||
return (
|
||
<div key={group.key} data-sidebar-group={group.key}>
|
||
{group.title ? (
|
||
<div className="flex items-center pt-3 pb-0.5">
|
||
<button
|
||
type="button"
|
||
className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg px-2.5 py-1 text-ui-label font-semibold tracking-wider uppercase text-foreground/65 dark:text-muted-foreground/80 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
|
||
onClick={() => {
|
||
if (group.emptySpaceId) {
|
||
openSpaceChat(group.emptySpaceId, "/onboarding");
|
||
return;
|
||
}
|
||
toggleSidebarSection(group.key);
|
||
}}
|
||
onContextMenu={
|
||
group.sectionId
|
||
? (event) => {
|
||
event.preventDefault();
|
||
// Prefer section rename over delete-space when both apply;
|
||
// the dedicated space-actions button still opens the space menu.
|
||
const sections =
|
||
group.spaceId === bootstrapMe?.spaceId
|
||
? botSections
|
||
: (spaces.find((space) => space.id === group.spaceId)
|
||
?.botSections ?? []);
|
||
const section = sections.find(
|
||
(item) => item.id === group.sectionId,
|
||
);
|
||
if (!section) return;
|
||
sectionMenuAnchor.current = event.currentTarget;
|
||
setSectionMenu({
|
||
section,
|
||
spaceId: group.spaceId,
|
||
position: { x: event.clientX, y: event.clientY },
|
||
});
|
||
}
|
||
: group.canDeleteSpace
|
||
? (event) => {
|
||
event.preventDefault();
|
||
spaceMenuAnchor.current = event.currentTarget;
|
||
setSpaceMenu({
|
||
id: group.spaceId,
|
||
position: { x: event.clientX, y: event.clientY },
|
||
});
|
||
}
|
||
: undefined
|
||
}
|
||
aria-expanded={group.emptySpaceId ? undefined : !collapsed}
|
||
aria-label={
|
||
group.emptySpaceId
|
||
? t`Open ${group.title}`
|
||
: collapsed
|
||
? t`Expand ${group.title}`
|
||
: t`Collapse ${group.title}`
|
||
}
|
||
>
|
||
<span className="flex min-w-0 items-center gap-1.5 truncate">
|
||
<Folder strokeWidth={2} aria-hidden="true" className="size-icon-sm" />
|
||
<span className="truncate">{group.title}</span>
|
||
</span>
|
||
{group.emptySpaceId ? null : (
|
||
<ChevronDown
|
||
strokeWidth={1.8}
|
||
className={`size-icon-md ${collapsed ? "-rotate-90 transition-transform" : "transition-transform"}`}
|
||
aria-hidden="true"
|
||
/>
|
||
)}
|
||
</button>
|
||
{group.canDeleteSpace ? (
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
aria-label={t`Actions for ${group.spaceName}`}
|
||
onClick={(event) => {
|
||
const rect = event.currentTarget.getBoundingClientRect();
|
||
spaceMenuAnchor.current = event.currentTarget;
|
||
setSpaceMenu({
|
||
id: group.spaceId,
|
||
position: { x: rect.left, y: rect.bottom },
|
||
});
|
||
}}
|
||
>
|
||
<MoreHorizontal aria-hidden="true" className="size-icon-md" />
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
{!collapsed &&
|
||
group.bots
|
||
.slice(0, expandedTeamGroups[group.key] ? undefined : 4)
|
||
.map((item) => (
|
||
<button
|
||
key={`${item.kind}:${item.chat.id}`}
|
||
type="button"
|
||
draggable={item.kind === "bot"}
|
||
data-roster-bot-id={item.kind === "bot" ? item.chat.id : undefined}
|
||
aria-keyshortcuts={
|
||
item.kind === "bot" ? "Alt+ArrowUp Alt+ArrowDown" : undefined
|
||
}
|
||
onDragStart={(event) => {
|
||
if (item.kind !== "bot") return;
|
||
setDraggedBotId(item.chat.id);
|
||
event.dataTransfer.effectAllowed = "move";
|
||
event.dataTransfer.setData("text/plain", item.chat.id);
|
||
}}
|
||
onDragOver={(event) => {
|
||
if (
|
||
item.kind === "bot" &&
|
||
draggedBotId &&
|
||
groupBotIds.includes(draggedBotId)
|
||
) {
|
||
event.preventDefault();
|
||
event.dataTransfer.dropEffect = "move";
|
||
}
|
||
}}
|
||
onDrop={(event) => {
|
||
if (item.kind !== "bot" || !draggedBotId) return;
|
||
event.preventDefault();
|
||
reorderRosterBot(draggedBotId, item.chat.id, groupBotIds);
|
||
setDraggedBotId(null);
|
||
}}
|
||
onDragEnd={() => setDraggedBotId(null)}
|
||
onKeyDown={(event) => {
|
||
if (
|
||
item.kind !== "bot" ||
|
||
!event.altKey ||
|
||
(event.key !== "ArrowUp" && event.key !== "ArrowDown")
|
||
)
|
||
return;
|
||
const index = groupBotIds.indexOf(item.chat.id);
|
||
const target =
|
||
groupBotIds[index + (event.key === "ArrowUp" ? -1 : 1)];
|
||
if (!target) return;
|
||
event.preventDefault();
|
||
reorderRosterBot(item.chat.id, target, groupBotIds);
|
||
}}
|
||
aria-label={
|
||
item.kind === "bot"
|
||
? t`${item.chat.name}, 1:1 conversation`
|
||
: t`${item.chat.name}, group conversation`
|
||
}
|
||
onClick={() => {
|
||
setActiveNavPillar("team");
|
||
openSpaceChat(item.chat.spaceId, `/app/${item.chat.id}`);
|
||
}}
|
||
onContextMenu={(event) => {
|
||
if (item.chat.spaceId !== bootstrapMe?.spaceId) return;
|
||
event.preventDefault();
|
||
botMenuAnchor.current = event.currentTarget;
|
||
setBotMenu({
|
||
kind: item.kind,
|
||
id: item.chat.id,
|
||
position: { x: event.clientX, y: event.clientY },
|
||
});
|
||
}}
|
||
className={`${rosterRowClass(
|
||
!inGroup && active?.id === item.chat.id,
|
||
)} cursor-grab active:cursor-grabbing`}
|
||
style={{
|
||
opacity: draggedBotId === item.chat.id ? 0.55 : 1,
|
||
}}
|
||
>
|
||
<div className="relative shrink-0">
|
||
<BotAvatar
|
||
color={item.chat.color}
|
||
identity={item.chat.id}
|
||
size={40}
|
||
status={item.chat.status}
|
||
/>
|
||
<span
|
||
className={`absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-sidebar ${
|
||
rosterStatusDot(item.chat.status) === "idle"
|
||
? "bg-success"
|
||
: "bg-warning animate-pulse"
|
||
}`}
|
||
/>
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-center justify-between gap-1.5">
|
||
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
|
||
<span
|
||
dir="auto"
|
||
data-roster-bot-name=""
|
||
className={`shrink-0 max-w-[170px] truncate text-ui-body tracking-tight ${rosterNameClass(
|
||
item.chat.unread,
|
||
)}`}
|
||
>
|
||
{item.chat.name}
|
||
</span>
|
||
{needsYouChipLabel(item.chat.status) ? (
|
||
<span className="shrink-0 rounded-full border border-warning/30 bg-warning/10 px-2 py-0.5 text-ui-label font-medium text-foreground dark:text-warning">
|
||
{needsYouChipLabel(item.chat.status)}
|
||
</span>
|
||
) : item.chat.title ? (
|
||
<span className="min-w-0 truncate rounded-full border border-border bg-muted px-2 py-0.5 text-ui-label font-medium text-muted-foreground">
|
||
{item.chat.title}
|
||
</span>
|
||
) : null}
|
||
{item.chat.unread ? (
|
||
<span className="sr-only">
|
||
<Trans> (unread)</Trans>
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-1.5">
|
||
<span className={rosterTimeClass}>
|
||
{formatRosterTime(item.chat.updatedAt)}
|
||
</span>
|
||
{item.chat.unread ? (
|
||
<span aria-hidden="true" className={rosterUnreadDotClass} />
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<div
|
||
dir="auto"
|
||
className={`mt-0.5 truncate text-ui-body leading-snug ${rosterPreviewClass(
|
||
item.chat.unread,
|
||
)}`}
|
||
>
|
||
{rosterActivitySubtitle(
|
||
item.chat.status,
|
||
cleanRosterPreview(item.chat.preview),
|
||
)}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
))}
|
||
{!collapsed && group.bots.length > 4 && (
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
setExpandedTeamGroups((prev) => ({
|
||
...prev,
|
||
[group.key]: !prev[group.key],
|
||
}))
|
||
}
|
||
className="mt-1 w-full flex items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-ui-label font-medium rk-sidebar-muted hover:bg-sidebar-accent hover:text-foreground transition-colors"
|
||
>
|
||
{expandedTeamGroups[group.key]
|
||
? t`Show less`
|
||
: t`Show all ${group.bots.length}`}
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
<div className="sidebar-projekte">
|
||
{/* PROJEKTE SECTION */}
|
||
<div className="flex items-center justify-between pt-4 pb-1 mt-3 border-t border-sidebar-border px-2.5">
|
||
<div>
|
||
<div className="rk-shell-section-label">{t`Projects`}</div>
|
||
<p className="mt-0.5 px-0.5 text-ui-caption rk-sidebar-muted">
|
||
{t`Threads by topic · folders`}
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<button
|
||
type="button"
|
||
aria-label={t`Create a project`}
|
||
data-testid="create-project-trigger"
|
||
onClick={() => openNewSpaceDialog("project")}
|
||
className="rk-sidebar-muted hover:text-foreground/90 transition-colors"
|
||
>
|
||
<Plus strokeWidth={2.5} className="size-icon-md" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{projectSidebarGroups.length === 0 ? (
|
||
<div className="p-4 text-center text-ui-label rk-sidebar-muted">
|
||
<p>{t`No projects yet.`}</p>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="mt-2 text-ui-label"
|
||
onClick={() => openNewSpaceDialog("project")}
|
||
>
|
||
<Plus className="mr-1 size-icon-sm" />
|
||
{t`Create a project`}
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
<DndContext
|
||
sensors={sensors}
|
||
collisionDetection={closestCenter}
|
||
onDragEnd={handleProjectDragEnd}
|
||
onDragStart={handleProjectDragStart}
|
||
onDragCancel={() => setDraggedProjectId(null)}
|
||
>
|
||
<SortableContext
|
||
items={projectSidebarGroups.filter((g) => g.title).map((g) => g.spaceId)}
|
||
strategy={verticalListSortingStrategy}
|
||
>
|
||
{projectSidebarGroups.map((group) => {
|
||
const collapsed =
|
||
Boolean(group.title) && collapsedSidebarSections.has(group.key);
|
||
return (
|
||
<SortableGroup key={`proj-${group.key}`} group={group}>
|
||
<div data-sidebar-group={group.key}>
|
||
{group.title ? (
|
||
<div className="flex items-center pt-3 pb-0.5 relative">
|
||
{/* Drag grip — positioned to the left of the title text */}
|
||
<ProjectDragGrip className="absolute left-1 top-1/2 -translate-y-1/2 w-6 h-6 rounded flex items-center justify-center text-muted-foreground/30 hover:text-muted-foreground/70 active:text-muted-foreground transition-colors cursor-grab active:cursor-grabbing">
|
||
<GripVertical className="size-icon-sm" strokeWidth={2} />
|
||
</ProjectDragGrip>
|
||
<button
|
||
type="button"
|
||
className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg pl-8 py-1 text-ui-label font-semibold tracking-wider uppercase text-foreground/65 dark:text-muted-foreground/80 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
|
||
onClick={() => {
|
||
if (group.emptySpaceId) {
|
||
void startProject(group.emptySpaceId);
|
||
return;
|
||
}
|
||
toggleSidebarSection(group.key);
|
||
}}
|
||
onContextMenu={
|
||
group.sectionId
|
||
? (event) => {
|
||
event.preventDefault();
|
||
const sections =
|
||
group.spaceId === bootstrapMe?.spaceId
|
||
? botSections
|
||
: (spaces.find(
|
||
(space) => space.id === group.spaceId,
|
||
)?.botSections ?? []);
|
||
const section = sections.find(
|
||
(item) => item.id === group.sectionId,
|
||
);
|
||
if (!section) return;
|
||
sectionMenuAnchor.current = event.currentTarget;
|
||
setSectionMenu({
|
||
section,
|
||
spaceId: group.spaceId,
|
||
position: { x: event.clientX, y: event.clientY },
|
||
});
|
||
}
|
||
: group.canDeleteSpace
|
||
? (event) => {
|
||
event.preventDefault();
|
||
spaceMenuAnchor.current = event.currentTarget;
|
||
setSpaceMenu({
|
||
id: group.spaceId,
|
||
position: { x: event.clientX, y: event.clientY },
|
||
});
|
||
}
|
||
: undefined
|
||
}
|
||
aria-expanded={group.emptySpaceId ? undefined : !collapsed}
|
||
aria-label={
|
||
group.emptySpaceId
|
||
? t`Select ${group.title}`
|
||
: collapsed
|
||
? t`Expand ${group.title}`
|
||
: t`Collapse ${group.title}`
|
||
}
|
||
>
|
||
<span className="flex min-w-0 items-center gap-1.5 truncate">
|
||
{group.showLock ? (
|
||
<Lock
|
||
strokeWidth={2}
|
||
aria-hidden="true"
|
||
className="size-icon-sm"
|
||
/>
|
||
) : null}
|
||
<span className="truncate">{group.title}</span>
|
||
</span>
|
||
{group.emptySpaceId ? null : (
|
||
<ChevronDown
|
||
strokeWidth={1.8}
|
||
className={`size-icon-md ${collapsed ? "-rotate-90 transition-transform" : "transition-transform"}`}
|
||
aria-hidden="true"
|
||
/>
|
||
)}
|
||
</button>
|
||
{group.canDeleteSpace ? (
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
aria-label={t`Actions for ${group.spaceName}`}
|
||
onClick={(event) => {
|
||
const rect = event.currentTarget.getBoundingClientRect();
|
||
spaceMenuAnchor.current = event.currentTarget;
|
||
setSpaceMenu({
|
||
id: group.spaceId,
|
||
position: { x: rect.left, y: rect.bottom },
|
||
});
|
||
}}
|
||
>
|
||
<MoreHorizontal aria-hidden="true" className="size-icon-md" />
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
{!collapsed && group.startSpaceId ? (
|
||
<div className="px-3 pb-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="w-full text-ui-label"
|
||
data-testid={`project-start-${group.spaceId}`}
|
||
onClick={() => void startProject(group.spaceId)}
|
||
>
|
||
<Plus className="mr-1 size-icon-sm" />
|
||
{t`Start`}
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
{!collapsed &&
|
||
group.bots.map((item) => (
|
||
<button
|
||
key={`${item.kind}:${item.chat.id}`}
|
||
type="button"
|
||
aria-label={
|
||
item.kind === "bot"
|
||
? t`${item.chat.name}, 1:1 conversation`
|
||
: item.chat.kind === "main"
|
||
? t`${item.chat.name}, main conversation`
|
||
: t`${item.chat.name}, group conversation`
|
||
}
|
||
onClick={() => {
|
||
setActiveNavPillar("projekte");
|
||
openSpaceChat(
|
||
item.chat.spaceId,
|
||
item.kind === "bot"
|
||
? `/app/${item.chat.id}`
|
||
: `/app/g/${item.chat.id}`,
|
||
);
|
||
}}
|
||
onContextMenu={(event) => {
|
||
// A project's main conversation has no chat actions.
|
||
if (
|
||
item.chat.spaceId !== bootstrapMe?.spaceId ||
|
||
item.chat.kind === "main"
|
||
)
|
||
return;
|
||
event.preventDefault();
|
||
botMenuAnchor.current = event.currentTarget;
|
||
setBotMenu({
|
||
kind: item.kind,
|
||
id: item.chat.id,
|
||
position: { x: event.clientX, y: event.clientY },
|
||
});
|
||
}}
|
||
className={rosterRowClass(
|
||
item.kind === "bot"
|
||
? !inGroup && active?.id === item.chat.id
|
||
: inGroup && activeGroup?.id === item.chat.id,
|
||
)}
|
||
>
|
||
{item.kind === "bot" ? (
|
||
<BotAvatar
|
||
color={item.chat.color}
|
||
identity={item.chat.id}
|
||
size={40}
|
||
status={item.chat.status}
|
||
/>
|
||
) : (
|
||
<GroupAvatar members={(item.chat as any).members} size={40} />
|
||
)}
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-center justify-between gap-1.5">
|
||
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
|
||
<span
|
||
dir="auto"
|
||
className={`shrink-0 max-w-[170px] truncate text-ui-body tracking-tight ${rosterNameClass(
|
||
item.chat.unread,
|
||
)}`}
|
||
>
|
||
{item.chat.name}
|
||
</span>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-1.5">
|
||
<span className={rosterTimeClass}>
|
||
{formatRosterTime(item.chat.updatedAt)}
|
||
</span>
|
||
{item.chat.unread ? (
|
||
<span
|
||
aria-hidden="true"
|
||
className={rosterUnreadDotClass}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<div
|
||
dir="auto"
|
||
className={`mt-0.5 truncate text-ui-body leading-snug ${rosterPreviewClass(
|
||
item.chat.unread,
|
||
)}`}
|
||
>
|
||
{cleanRosterPreview(localizedPreview(item.chat))}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</SortableGroup>
|
||
);
|
||
})}
|
||
</SortableContext>
|
||
</DndContext>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
{archivedBots.length + archivedGroups.length > 0 && !showSpaceSearch ? (
|
||
<div className="mt-2 border-t border-border pt-2">
|
||
<button
|
||
type="button"
|
||
aria-expanded={archivedOpen}
|
||
onClick={() => setArchivedOpen((open) => !open)}
|
||
className="flex w-full items-center justify-between rounded-lg px-2.5 py-2 text-ui-body rk-sidebar-muted hover:bg-sidebar-accent"
|
||
>
|
||
<span>
|
||
<Trans>Archived</Trans>
|
||
</span>
|
||
<span>{archivedBots.length + archivedGroups.length}</span>
|
||
</button>
|
||
{archivedOpen ? (
|
||
<>
|
||
{archivedBots.map((bot) => (
|
||
<div key={bot.id} className="flex items-center gap-2 rounded-lg px-2.5 py-2">
|
||
<BotAvatar
|
||
color={bot.color}
|
||
identity={bot.id}
|
||
size={28}
|
||
status={bot.status}
|
||
/>
|
||
<span
|
||
className="min-w-0 flex-1 truncate text-ui-body text-foreground/75"
|
||
dir="auto"
|
||
>
|
||
{bot.name}
|
||
</span>
|
||
<Button
|
||
variant="ghost"
|
||
size="xs"
|
||
onClick={() =>
|
||
void rpc.bots.restore({ botId: bot.id }).then(() => refreshBots(true))
|
||
}
|
||
>
|
||
<Trans>Restore</Trans>
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="xs"
|
||
className="text-destructive hover:text-destructive"
|
||
aria-label={t`Delete ${bot.name}`}
|
||
onClick={() => setDeleteTarget(bot)}
|
||
>
|
||
<Trans>Delete</Trans>
|
||
</Button>
|
||
</div>
|
||
))}
|
||
{archivedGroups.map((group) => (
|
||
<div key={group.id} className="flex items-center gap-2 rounded-lg px-2.5 py-2">
|
||
<GroupAvatar members={group.members} size={28} />
|
||
<span
|
||
className="min-w-0 flex-1 truncate text-ui-body text-foreground/75"
|
||
dir="auto"
|
||
>
|
||
{group.name}
|
||
</span>
|
||
<Button
|
||
variant="ghost"
|
||
size="xs"
|
||
onClick={() =>
|
||
void rpc.groups
|
||
.restore({ groupId: group.id })
|
||
.then(() => refreshBots(true))
|
||
}
|
||
>
|
||
<Trans>Restore</Trans>
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="xs"
|
||
className="text-destructive hover:text-destructive"
|
||
aria-label={t`Delete ${group.name}`}
|
||
onClick={() => setDeleteGroupTarget(group)}
|
||
>
|
||
<Trans>Delete</Trans>
|
||
</Button>
|
||
</div>
|
||
))}
|
||
</>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<Popover open={menuOpen} onOpenChange={setMenuOpen}>
|
||
<PopoverTrigger
|
||
data-testid="user-menu-trigger"
|
||
className="flex items-center gap-[11px] px-[18px] py-3.5"
|
||
>
|
||
<span className="grid size-control-lg place-items-center rounded-full bg-accent text-ui-body-sm text-foreground/75">
|
||
{initials}
|
||
</span>
|
||
<span className="text-[14.5px] text-foreground/90">{userName}</span>
|
||
</PopoverTrigger>
|
||
{menuOpen ? (
|
||
<PopoverContent
|
||
side="top"
|
||
align="start"
|
||
className="w-[calc(316px-1.5rem)] max-w-[calc(100vw-3rem)] gap-0 p-1 data-closed:animate-none"
|
||
>
|
||
<Button
|
||
variant="ghost"
|
||
className="w-full justify-start font-normal"
|
||
aria-label={t`Settings`}
|
||
onClick={() => {
|
||
setMenuOpen(false);
|
||
openSettings("general");
|
||
}}
|
||
>
|
||
<Settings className="text-muted-foreground" strokeWidth={1.75} />
|
||
<Trans>Settings</Trans>
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
className="w-full justify-start font-normal"
|
||
aria-label={t`Usage`}
|
||
onClick={() => {
|
||
setMenuOpen(false);
|
||
void rpc.usage
|
||
.summary()
|
||
.then(setUsage)
|
||
.catch(() => undefined);
|
||
openSettings("usage");
|
||
}}
|
||
>
|
||
<Gauge className="text-muted-foreground" strokeWidth={1.75} />
|
||
<Trans>Usage</Trans>
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
className="w-full justify-start font-normal"
|
||
onClick={() =>
|
||
// This browser stops receiving the account's pushes once it signs out.
|
||
void disableWebPush()
|
||
.catch(() => undefined)
|
||
.then(() => authClient.signOut())
|
||
.then(() => {
|
||
clearSpaceSelection();
|
||
navigate("/");
|
||
})
|
||
}
|
||
>
|
||
<LogOut className="text-muted-foreground" strokeWidth={1.75} />
|
||
<Trans>Log out</Trans>
|
||
</Button>
|
||
</PopoverContent>
|
||
) : null}
|
||
</Popover>
|
||
</aside>
|
||
|
||
<button
|
||
type="button"
|
||
data-testid="bots-sidebar-edge"
|
||
aria-label={botsSidebarCollapsed ? t`Show bots` : t`Hide bots`}
|
||
aria-pressed={!botsSidebarCollapsed}
|
||
className={`absolute inset-y-0 z-50 hidden w-2 cursor-ew-resize touch-none border-0 bg-transparent p-0 md:block ${
|
||
botsSidebarCollapsed ? "start-0" : "start-[308px]"
|
||
}`}
|
||
onPointerDown={(event) => {
|
||
event.currentTarget.setPointerCapture(event.pointerId);
|
||
botsSidebarEdgeDragRef.current = {
|
||
startX: event.clientX,
|
||
mode: botsSidebarCollapsed ? "expand" : "collapse",
|
||
};
|
||
}}
|
||
onPointerMove={(event) => {
|
||
const drag = botsSidebarEdgeDragRef.current;
|
||
if (!drag) return;
|
||
const rtl =
|
||
typeof document !== "undefined" &&
|
||
document.documentElement.getAttribute("dir") === "rtl";
|
||
const delta = rtl ? drag.startX - event.clientX : event.clientX - drag.startX;
|
||
if (drag.mode === "expand" && delta >= BOTS_SIDEBAR_EDGE_DRAG_PX) {
|
||
botsSidebarEdgeDragRef.current = null;
|
||
setBotsSidebarCollapsedPref(false);
|
||
} else if (drag.mode === "collapse" && delta <= -BOTS_SIDEBAR_EDGE_DRAG_PX) {
|
||
botsSidebarEdgeDragRef.current = null;
|
||
setBotsSidebarCollapsedPref(true);
|
||
}
|
||
}}
|
||
onPointerUp={(event) => {
|
||
const drag = botsSidebarEdgeDragRef.current;
|
||
botsSidebarEdgeDragRef.current = null;
|
||
if (!drag) return;
|
||
if (Math.abs(event.clientX - drag.startX) < BOTS_SIDEBAR_EDGE_DRAG_PX) {
|
||
setBotsSidebarCollapsedPref(!botsSidebarCollapsed);
|
||
}
|
||
}}
|
||
onPointerCancel={() => {
|
||
botsSidebarEdgeDragRef.current = null;
|
||
}}
|
||
/>
|
||
|
||
<main
|
||
aria-hidden={mobileSidebarOpen || undefined}
|
||
inert={mobileSidebarOpen}
|
||
className="flex min-w-0 flex-1 flex-col bg-background md:rounded-2xl md:border md:border-border md:shadow-2xl overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]"
|
||
>
|
||
{activeNavPillar === "heute" ? (
|
||
<div className="flex h-full flex-1 flex-col overflow-hidden">
|
||
<div className="app-drag flex items-center border-b border-sidebar-border px-3 py-[17px] md:px-[22px] lg:px-8 2xl:px-12">
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
{botsSidebarCollapsed && desktopBridge() ? <WindowChrome /> : null}
|
||
<button
|
||
type="button"
|
||
aria-label={t`Open navigation`}
|
||
onClick={() => setMobileSidebarOpen(true)}
|
||
className="app-no-drag grid size-control-lg shrink-0 place-items-center rounded-lg text-foreground/75 hover:bg-accent md:hidden"
|
||
>
|
||
<Menu strokeWidth={1.7} className="size-icon-lg" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
data-testid="toggle-bots-sidebar"
|
||
aria-label={botsSidebarCollapsed ? t`Show bots` : t`Minimize bots`}
|
||
title={botsSidebarCollapsed ? t`Show bots` : t`Minimize bots`}
|
||
onClick={() => setBotsSidebarCollapsedPref(!botsSidebarCollapsed)}
|
||
className="app-no-drag hidden size-control-lg shrink-0 place-items-center rounded-lg text-foreground/75 hover:bg-accent md:grid"
|
||
>
|
||
{botsSidebarCollapsed ? (
|
||
<PanelLeftOpen strokeWidth={1.7} aria-hidden="true" className="size-icon-lg" />
|
||
) : (
|
||
<PanelLeftClose strokeWidth={1.7} aria-hidden="true" className="size-icon-lg" />
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<HeuteBrief
|
||
bots={bots}
|
||
groups={groups}
|
||
onOpenBot={(botId, spaceId) => {
|
||
setActiveNavPillar("team");
|
||
openSpaceChat(spaceId, `/app/${botId}`);
|
||
}}
|
||
onOpenGroup={(groupId, spaceId) => {
|
||
setActiveNavPillar("projekte");
|
||
openSpaceChat(spaceId, `/app/g/${groupId}`);
|
||
}}
|
||
onOpenRun={(run) => {
|
||
if (run.groupId) {
|
||
setActiveNavPillar("projekte");
|
||
navigate(`/app/g/${run.groupId}`);
|
||
} else {
|
||
setActiveNavPillar("team");
|
||
navigate(`/app/${run.botId}`);
|
||
}
|
||
}}
|
||
onStartChat={() => {
|
||
const first = bots[0];
|
||
if (first) {
|
||
setActiveNavPillar("team");
|
||
openSpaceChat(first.spaceId, `/app/${first.id}`);
|
||
return;
|
||
}
|
||
setCreateMenuOpen(true);
|
||
}}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="app-drag flex items-center justify-between border-b border-sidebar-border px-3.5 py-2 md:px-6 md:py-3 lg:px-8 lg:py-4 2xl:px-12 2xl:py-6 bg-background/80 backdrop-blur-md">
|
||
<div className="flex min-w-0 items-center gap-2.5">
|
||
{/* Collapsed bots sidebar: this header is the leading edge for window chrome. */}
|
||
{botsSidebarCollapsed && desktopBridge() ? <WindowChrome /> : null}
|
||
<button
|
||
type="button"
|
||
aria-label={t`Open navigation`}
|
||
onClick={() => setMobileSidebarOpen(true)}
|
||
className="app-no-drag grid size-control-lg shrink-0 place-items-center rounded-lg text-foreground/75 hover:bg-accent md:hidden"
|
||
>
|
||
<Menu strokeWidth={1.7} className="size-icon-lg" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
data-testid="toggle-bots-sidebar"
|
||
aria-label={botsSidebarCollapsed ? t`Show bots` : t`Minimize bots`}
|
||
title={botsSidebarCollapsed ? t`Show bots` : t`Minimize bots`}
|
||
onClick={() => setBotsSidebarCollapsedPref(!botsSidebarCollapsed)}
|
||
className="app-no-drag hidden size-control-lg shrink-0 place-items-center rounded-lg text-foreground/75 hover:bg-accent md:grid"
|
||
>
|
||
{botsSidebarCollapsed ? (
|
||
<PanelLeftOpen strokeWidth={1.7} aria-hidden="true" className="size-icon-lg" />
|
||
) : (
|
||
<PanelLeftClose strokeWidth={1.7} aria-hidden="true" className="size-icon-lg" />
|
||
)}
|
||
</button>
|
||
{inGroup && projectSessionKey ? (
|
||
<button
|
||
type="button"
|
||
aria-label={t`Back to timeline`}
|
||
onClick={closeProjectSession}
|
||
className="app-no-drag grid size-control-lg shrink-0 place-items-center rounded-lg text-foreground/75 hover:bg-accent"
|
||
>
|
||
<ArrowLeft strokeWidth={1.8} aria-hidden="true" className="size-icon-lg" />
|
||
</button>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
data-testid="bot-settings-trigger"
|
||
onClick={() => setPanel(inGroup ? "group-settings" : "settings")}
|
||
className="app-no-drag flex min-w-0 items-center gap-3"
|
||
>
|
||
{!inGroup && active ? (
|
||
<BotAvatar
|
||
color={active.color}
|
||
identity={active.id}
|
||
size={36}
|
||
status={active.status}
|
||
className="shrink-0"
|
||
/>
|
||
) : inGroup ? (
|
||
<GroupAvatar
|
||
members={activeGroup?.members ?? activeSnapshot?.members ?? []}
|
||
size={36}
|
||
className="shrink-0"
|
||
/>
|
||
) : null}
|
||
<div className="min-w-0 text-start flex flex-col justify-center">
|
||
<span
|
||
className="block truncate text-ui-body font-semibold text-foreground tracking-tight leading-snug"
|
||
dir="auto"
|
||
>
|
||
{inGroup
|
||
? (activeGroup?.name ?? activeSnapshot?.groupName ?? t`Group`)
|
||
: (active?.name ?? t`Choose a conversation`)}
|
||
</span>
|
||
{!inGroup && active && (composerRunning || active.status !== "idle") ? (
|
||
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
|
||
{composerRunning || active.status === "running" ? t`Busy` : active.status}
|
||
</span>
|
||
) : inGroup && projectSessionKey ? (
|
||
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
|
||
{t`Group conversation`}
|
||
</span>
|
||
) : inGroup && activeGroup?.kind !== "main" ? (
|
||
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
|
||
{t`Project · sessions & folders`}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</button>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
title={t`Browser`}
|
||
aria-label={t`Browser`}
|
||
onClick={() => setPanel(panel === "browser" ? null : "browser")}
|
||
data-active={panel === "browser" ? "" : undefined}
|
||
className="app-no-drag grid h-[30px] w-[34px] place-items-center rounded-[9px] hover:bg-accent data-active:bg-accent"
|
||
>
|
||
<Globe strokeWidth={1.7} className="text-foreground/75 size-icon-lg" />
|
||
</button>
|
||
{!inGroup && active ? (
|
||
<button
|
||
type="button"
|
||
title={t`Agent computer`}
|
||
onClick={() => {
|
||
const next = panel === "computer" ? null : "computer";
|
||
if (next === "computer" && transcriptRunning) learnScreenPane("opened");
|
||
setPanel(next);
|
||
if (next === "computer" && active) {
|
||
// Refresh run/computer so Take control isn't stuck on a stale busyBotName.
|
||
void refreshThread(active.id).catch(() => undefined);
|
||
}
|
||
}}
|
||
data-active={panel === "computer" ? "" : undefined}
|
||
className="app-no-drag grid h-[30px] w-[34px] place-items-center rounded-[9px] hover:bg-accent data-active:bg-accent"
|
||
>
|
||
<PanelRight strokeWidth={1.7} className="text-foreground/75 size-icon-lg" />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
{!active && !activeGroup && emptyProjectSpace && initialBotsLoaded ? (
|
||
<div className="grid flex-1 place-items-center">
|
||
<Button
|
||
data-testid="project-start-main"
|
||
onClick={() => currentSpaceId && void startProject(currentSpaceId)}
|
||
>
|
||
<Plus aria-hidden="true" className="size-icon-md" />
|
||
{t`Start`}
|
||
</Button>
|
||
</div>
|
||
) : !active && !activeGroup && initialBotsLoaded ? (
|
||
<div className="grid flex-1 place-items-center">
|
||
<Button onClick={() => setShowCreateBotModal(true)}>
|
||
<Plus aria-hidden="true" className="size-icon-md" />
|
||
<Trans>Create new Bot</Trans>
|
||
</Button>
|
||
</div>
|
||
) : showProjectTimeline && bots.length > 0 ? (
|
||
<ProjectSessionTimeline
|
||
sectionId={activeGroup!.sectionId!}
|
||
sectionName={activeProjectSection?.name ?? activeGroup?.name ?? t`Project`}
|
||
groupId={groupId!}
|
||
groupName={activeGroup?.name ?? activeSnapshot?.groupName ?? t`Group`}
|
||
onOpenSession={openProjectSession}
|
||
onStartSession={openProjectChat}
|
||
onOpenGroupChat={openProjectGroupChat}
|
||
onStartVoiceFirst={(goalGroupId) => openProjectVoiceFirst(goalGroupId)}
|
||
/>
|
||
) : showProjectTimeline && bots.length === 0 ? (
|
||
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-12">
|
||
<p className="text-ui-body text-muted-foreground">
|
||
<Trans>Create a bot first to start a project.</Trans>
|
||
</p>
|
||
<Button onClick={() => setShowCreateBotModal(true)}>
|
||
<Plus aria-hidden="true" className="size-icon-md" />
|
||
<Trans>Create new Bot</Trans>
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{!callOpen && active && !inGroup ? (
|
||
<OoStage
|
||
botId={active.id}
|
||
botName={active.name}
|
||
botTitle={active.title}
|
||
botDescription={active.description}
|
||
botColor={active.color}
|
||
botStatus={active.status}
|
||
snapshot={activeSnapshot}
|
||
running={transcriptRunning}
|
||
hasMessages={transcriptMessages.length > 0}
|
||
voiceReady={Boolean(voiceStatus?.ready)}
|
||
onStartVoice={startVoiceEncounter}
|
||
/>
|
||
) : null}
|
||
{!callOpen && inGroup ? (
|
||
<OoGroupStage
|
||
groupName={activeGroup?.name ?? activeSnapshot?.groupName}
|
||
snapshot={activeSnapshot}
|
||
enrichMember={enrichStageMember}
|
||
voiceReady={Boolean(voiceStatus?.ready)}
|
||
onStartVoice={startVoiceEncounter}
|
||
/>
|
||
) : null}
|
||
<Transcript
|
||
key={activeSnapshot?.threadId}
|
||
encounterMode={encounterMode}
|
||
voiceEncounterActive={voiceEncounterActive}
|
||
activeName={
|
||
inGroup ? (activeGroup?.name ?? activeSnapshot?.groupName) : active?.name
|
||
}
|
||
scrollRef={messageScroll}
|
||
artifactTarget={transcriptArtifactTarget}
|
||
onOpenArtifact={wideLayout ? openArtifactBeside : undefined}
|
||
messages={transcriptMessages}
|
||
olderCursor={activeSnapshot?.olderCursor ?? null}
|
||
loadingOlder={loadingOlder}
|
||
answerableAskMessageId={answerableAskMessageId}
|
||
running={transcriptRunning}
|
||
workingBots={workingBots}
|
||
onLoadOlder={loadOlder}
|
||
onOpenBot={openBot}
|
||
onOpenGroup={openGroup}
|
||
onAnswer={answerMessage}
|
||
onReply={(message) => {
|
||
setReplyTarget(message);
|
||
setReplyQuote(null);
|
||
}}
|
||
onQuote={(message, quote) => {
|
||
setReplyTarget(message);
|
||
setReplyQuote(quote);
|
||
}}
|
||
onReact={reactToMessage}
|
||
onJumpToMessage={jumpToReplyMessage}
|
||
onOpenPeerMessages={(peer) => {
|
||
setPeerConversation(peer);
|
||
}}
|
||
memberName={resolveTranscriptMemberName}
|
||
peerBot={resolveSpeakerBot}
|
||
onRefresh={refreshActiveThread}
|
||
onBotChanged={refreshBots}
|
||
onAddRoutine={addSkillRoutine}
|
||
voiceReady={Boolean(voiceStatus?.ready)}
|
||
speakingMessageId={speakingMessageId}
|
||
onSpeak={speakMessage}
|
||
/>
|
||
</>
|
||
)}
|
||
{activeGroup?.kind === "main" && !showProjectTimeline ? (
|
||
<MainInbox
|
||
bots={bots}
|
||
onOpenBot={openBot}
|
||
onOpenGroup={openGroup}
|
||
onOpenInSpace={openSpaceChat}
|
||
/>
|
||
) : null}
|
||
{recordingSkill ? (
|
||
<div className="px-6 pb-2 text-center text-ui-body-sm text-destructive">
|
||
<Trans>Teaching in progress. Stop teaching before sending a new message.</Trans>
|
||
</div>
|
||
) : null}
|
||
{(active || activeGroup) && !showProjectTimeline ? (
|
||
<Composer
|
||
key={inGroup ? `group:${groupId}` : `bot:${active?.id}`}
|
||
running={composerRunning}
|
||
disabled={Boolean(recordingSkill)}
|
||
pendingAttachments={activePendingAttachments}
|
||
attachmentNotice={attachmentNotice}
|
||
sendError={sendError}
|
||
runError={displayedRunError}
|
||
runErrorId={displayedRunErrorId}
|
||
onRunErrorPresented={handleRunErrorPresented}
|
||
onDismissError={dismissComposerError}
|
||
sending={sending}
|
||
fileInputRef={fileInputRef}
|
||
onAttachmentPick={onAttachmentPick}
|
||
onRemoveAttachment={removeAttachment}
|
||
onSend={sendMessage}
|
||
onStop={stopRun}
|
||
onVoice={
|
||
active || inGroup
|
||
? () => {
|
||
startVoiceEncounter();
|
||
}
|
||
: undefined
|
||
}
|
||
voiceReady={Boolean(voiceStatus?.ready)}
|
||
voiceColor={voiceCallBot?.color}
|
||
replyTarget={activeReplyTarget}
|
||
replyQuote={activeReplyQuote}
|
||
replyTargetName={replyTargetName}
|
||
onClearReply={clearReply}
|
||
mentionTargets={composerMentionTargets}
|
||
agentSkills={agentSkills}
|
||
onSlashOpen={refreshAgentSkills}
|
||
onSlashAction={(action) => {
|
||
if (action === "chat-settings") {
|
||
setPanel(inGroup ? "group-settings" : "settings");
|
||
return;
|
||
}
|
||
if (action === "settings-general") {
|
||
openSettings("general");
|
||
return;
|
||
}
|
||
if (action === "settings-usage") {
|
||
void rpc.usage
|
||
.summary()
|
||
.then(setUsage)
|
||
.catch(() => undefined);
|
||
openSettings("usage");
|
||
}
|
||
}}
|
||
/>
|
||
) : null}
|
||
</>
|
||
)}
|
||
</main>
|
||
|
||
<aside
|
||
data-testid="side-panel"
|
||
data-panel={panel ?? "closed"}
|
||
data-auto={panel !== null && panel === autoPanel ? "" : undefined}
|
||
style={{
|
||
width:
|
||
panel &&
|
||
(active ||
|
||
activeGroup ||
|
||
panel === "create" ||
|
||
panel === "create-group" ||
|
||
panel === "browser")
|
||
? workspacePaneOpen
|
||
? workspacePaneWidth
|
||
: rightSidebarWidth
|
||
: 0,
|
||
}}
|
||
className={`absolute inset-y-0 end-0 z-20 flex min-h-0 shrink-0 flex-col overflow-hidden bg-background md:rounded-2xl md:border md:border-border md:shadow-2xl md:relative ${
|
||
panel &&
|
||
(
|
||
active ||
|
||
activeGroup ||
|
||
panel === "create" ||
|
||
panel === "create-group" ||
|
||
panel === "browser"
|
||
)
|
||
? "max-w-[384px] md:max-w-none opacity-100"
|
||
: "pointer-events-none w-0 opacity-0 md:opacity-0 md:w-0"
|
||
} ${!(workspacePaneOpen ? isResizingWorkspacePane : isResizingRight) ? "transition-[width,opacity] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none" : ""}`}
|
||
>
|
||
<div
|
||
onPointerDown={workspacePaneOpen ? startResizingWorkspacePane : startResizingRight}
|
||
className="absolute -left-2 top-0 bottom-0 w-4 cursor-col-resize z-50 hidden md:block"
|
||
/>
|
||
{panel === "artifact" ? (
|
||
artifactPane ? (
|
||
<ArtifactPane
|
||
key={artifactPane.artifactId}
|
||
target={transcriptArtifactTarget}
|
||
artifact={artifactPane}
|
||
onClose={() => setPanel(null)}
|
||
/>
|
||
) : null
|
||
) : panel === "browser" ? (
|
||
<InAppBrowser onClose={() => setPanel(null)} />
|
||
) : panel && (active || activeGroup || panel === "create" || panel === "create-group") ? (
|
||
<div className="rk-scroll h-full w-full overflow-y-auto px-5 lg:px-7 2xl:px-9 py-[17px]">
|
||
{panel !== "routine" &&
|
||
panel !== "create" &&
|
||
panel !== "create-group" &&
|
||
panel !== "group-settings" ? (
|
||
<div className="mb-4 flex items-center justify-between">
|
||
<span className="text-ui-body text-muted-foreground">
|
||
{panel === "settings" ? (
|
||
<Trans>Settings</Trans>
|
||
) : active ? (
|
||
(computer?.state ?? active.status)
|
||
) : (
|
||
<Trans>Group</Trans>
|
||
)}
|
||
</span>
|
||
<div className="flex gap-1">
|
||
{active &&
|
||
panel === "computer" &&
|
||
!computerOpen &&
|
||
computerPanelNeedsMaintenance(computer?.state, booting) ? (
|
||
<ComputerMaintenanceActions
|
||
botId={active.id}
|
||
computer={computer}
|
||
onChanged={async () => {
|
||
await refreshThread(active.id);
|
||
}}
|
||
/>
|
||
) : null}
|
||
{active ? (
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
aria-label={panel === "settings" ? t`Show computer` : t`Show settings`}
|
||
onClick={() => setPanel(panel === "settings" ? "computer" : "settings")}
|
||
className={panel === "settings" ? "text-foreground" : "text-muted-foreground"}
|
||
>
|
||
<Settings strokeWidth={1.7} className="size-icon-md" />
|
||
</Button>
|
||
) : null}
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
aria-label={t`Close panel`}
|
||
onClick={() => setPanel(null)}
|
||
>
|
||
<X strokeWidth={1.8} className="size-icon-md" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{panel === "computer" && active ? (
|
||
<div>
|
||
<div
|
||
data-testid="computer-preview"
|
||
className="group relative aspect-[16/10] overflow-hidden rounded-[14px] bg-background"
|
||
>
|
||
{computerOpen ? (
|
||
<div className="grid h-full place-items-center text-sm text-muted-foreground/80">
|
||
<Trans>Open in full window</Trans>
|
||
</div>
|
||
) : computer?.kind === "desktop" ? (
|
||
<DesktopKindEmptyState className="grid h-full place-items-center px-6 text-center text-sm text-muted-foreground/80" />
|
||
) : computer?.state === "running" && embeddedScreenUrl && !computerScreenError ? (
|
||
<>
|
||
<iframe
|
||
title={t`Bot screen preview`}
|
||
src={embeddedScreenUrl}
|
||
sandbox={screenIframeSandbox(embeddedScreenUrl)}
|
||
className="h-full w-full border-0 bg-black"
|
||
allow="clipboard-read; clipboard-write"
|
||
style={{ pointerEvents: "none" }}
|
||
/>
|
||
<AgentCursor botId={active.id} color={active.color} name={active.name} />
|
||
</>
|
||
) : (
|
||
<div className="grid h-full place-items-center px-6 text-center text-sm text-muted-foreground/80">
|
||
{computerScreenError ??
|
||
(computersAreUnavailable(bootstrapMe?.sandboxProvider) ? (
|
||
<ComputersUnavailableHint />
|
||
) : (
|
||
computerPlaceholder(
|
||
computer?.state,
|
||
booting,
|
||
computerLabel(computer?.mode, active.name),
|
||
)
|
||
))}
|
||
</div>
|
||
)}
|
||
{!computerScreenError ? (
|
||
<button
|
||
type="button"
|
||
data-testid="computer-preview-open"
|
||
className="absolute inset-0 flex cursor-pointer items-center justify-center bg-overlay/40 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
||
aria-label={t`Open`}
|
||
onClick={() => void openComputer()}
|
||
>
|
||
<span className="inline-flex items-center gap-2 rounded-full bg-overlay px-3.5 py-2 text-ui-body text-foreground shadow-md">
|
||
<Maximize2 strokeWidth={1.9} aria-hidden className="size-icon-md" />
|
||
<Trans>Open</Trans>
|
||
</span>
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
<p className="mt-2 truncate text-ui-body text-muted-foreground" dir="auto">
|
||
{t`${active.name}'s screen`}
|
||
</p>
|
||
<RoutineListHeader
|
||
onCreate={() => {
|
||
setRoutineDraft(emptyRoutineDraft());
|
||
setRoutineWebhookSecret(null);
|
||
setEditingRoutine(null);
|
||
setRoutineError(null);
|
||
setPanel("routine");
|
||
}}
|
||
/>
|
||
{activeRoutines.map((routine) => {
|
||
const routineRunning =
|
||
snapshot?.run?.routineId === routine.id && isActive(snapshot.run.status);
|
||
return (
|
||
<RoutineListRow
|
||
key={routine.id}
|
||
routine={routine}
|
||
running={routineRunning}
|
||
onOpen={() => {
|
||
setRoutineDraft(draftFromRoutine(routine));
|
||
setRoutineWebhookSecret(null);
|
||
setEditingRoutine(routine);
|
||
setRoutineError(null);
|
||
setPanel("routine");
|
||
}}
|
||
onStop={() => void stopRun()}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
) : null}
|
||
{panel === "create-group" ? (
|
||
<CreateGroupForm
|
||
bots={emptyProjectSpace ? (homeSpaceBots as Bot[]) : bots}
|
||
onCancel={() => setPanel(null)}
|
||
onCreate={(input) => createGroup(input)}
|
||
/>
|
||
) : null}
|
||
{panel === "group-settings" && activeGroup ? (
|
||
<GroupSettings
|
||
key={activeGroup.id}
|
||
group={activeGroup}
|
||
bots={bots}
|
||
onSave={async (input) => {
|
||
const updated = await rpc.groups.update({ groupId: activeGroup.id, ...input });
|
||
setGroups((current) =>
|
||
current.map((group) => (group.id === updated.id ? updated : group)),
|
||
);
|
||
setPanel(null);
|
||
await Promise.all([refreshBots(), refreshGroupThread(activeGroup.id)]).catch(
|
||
() => undefined,
|
||
);
|
||
}}
|
||
onRemove={async () => {
|
||
await rpc.groups.remove({ groupId: activeGroup.id });
|
||
const remainingGroups = groups.filter((group) => group.id !== activeGroup.id);
|
||
setGroups(remainingGroups);
|
||
setPanel(null);
|
||
navigate(
|
||
firstThreadRoute(bots, remainingGroups, currentSpaceId !== homeSpaceId),
|
||
{ replace: true },
|
||
);
|
||
await refreshBots().catch(() => undefined);
|
||
}}
|
||
/>
|
||
) : null}
|
||
{panel === "create" ? (
|
||
<CreateBotForm
|
||
onCancel={() => setPanel(null)}
|
||
onCreate={(input) => createBot(input)}
|
||
/>
|
||
) : null}
|
||
{/* Centered Create Bot Modal */}
|
||
{showCreateBotModal &&
|
||
createPortal(
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||
<button
|
||
type="button"
|
||
className="absolute inset-0 bg-overlay"
|
||
aria-label={t`Close`}
|
||
onClick={() => setShowCreateBotModal(false)}
|
||
/>
|
||
<div className="relative z-10 max-h-[90vh] w-[480px] overflow-y-auto rounded-2xl border border-border bg-popover p-6 text-popover-foreground shadow-2xl">
|
||
<div className="mb-4 flex items-center justify-between">
|
||
<h2 className="text-lg font-semibold text-foreground">
|
||
<Trans>Create new Bot</Trans>
|
||
</h2>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowCreateBotModal(false)}
|
||
className="text-muted-foreground hover:text-foreground"
|
||
aria-label={t`Close`}
|
||
>
|
||
<X strokeWidth={1.8} className="size-icon-md" />
|
||
</button>
|
||
</div>
|
||
<CreateBotForm
|
||
onCancel={() => setShowCreateBotModal(false)}
|
||
onCreate={async (input) => {
|
||
await createBot(input);
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>,
|
||
document.body,
|
||
)}
|
||
{panel === "settings" && active ? (
|
||
<BotSettings
|
||
key={active.id}
|
||
bot={active}
|
||
memoryProviderConfigured={memoryProviderConfig != null}
|
||
onSkillsChange={setAgentSkills}
|
||
onSave={async ({ computerMode, ...patch }) => {
|
||
if (computerMode !== active.computerMode) {
|
||
await rpc.bots.setComputer({
|
||
botId: active.id,
|
||
mode: computerMode,
|
||
});
|
||
}
|
||
await rpc.bots.update({ botId: active.id, ...patch });
|
||
await refreshBots();
|
||
}}
|
||
onExport={async () => {
|
||
const manifest = await rpc.export.bot({ botId: active.id });
|
||
const blob = new Blob([JSON.stringify(manifest, null, 2)], {
|
||
type: "application/json",
|
||
});
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `${active.name.toLowerCase().replace(/\s+/g, "-")}-export.json`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}}
|
||
onClear={() => setClearTarget({ kind: "bot", chat: active })}
|
||
/>
|
||
) : null}
|
||
{panel === "routine" && active ? (
|
||
<RoutineEditor
|
||
draft={routineDraft}
|
||
onChange={setRoutineDraft}
|
||
editing={editingRoutine}
|
||
timezone={editingRoutine?.timezone ?? localTimezone()}
|
||
webhook={{
|
||
path:
|
||
typeof window !== "undefined"
|
||
? `${window.location.origin}/api/v1/bots/${active.id}/webhook`
|
||
: `/api/v1/bots/${active.id}/webhook`,
|
||
secret: routineWebhookSecret,
|
||
configured: active.webhookConfigured || Boolean(routineWebhookSecret),
|
||
}}
|
||
githubPath={
|
||
typeof window !== "undefined"
|
||
? `${window.location.origin}/api/v1/bots/${active.id}/github`
|
||
: `/api/v1/bots/${active.id}/github`
|
||
}
|
||
messageProviders={messagingProviders}
|
||
saving={savingRoutine}
|
||
running={runningRoutine}
|
||
error={routineError}
|
||
onBack={() => setPanel("computer")}
|
||
onClose={() => setPanel(null)}
|
||
onEnsureWebhook={async () => {
|
||
await ensureWebhookSecret(active.id);
|
||
}}
|
||
onSave={async () => {
|
||
if (routineSavePending.current) return;
|
||
const targetBotId = active.id;
|
||
const targetRoutine = editingRoutine;
|
||
if (targetRoutine && targetRoutine.botId !== targetBotId) return;
|
||
if (
|
||
!routineDraft.schedules.length &&
|
||
!routineDraft.webhookEnabled &&
|
||
!routineDraft.githubEnabled &&
|
||
!routineDraft.messageProvider
|
||
) {
|
||
setRoutineError(t`Add a schedule, webhook, GitHub, or message trigger`);
|
||
return;
|
||
}
|
||
const saveRequest = ++routineSaveRequest.current;
|
||
routineSavePending.current = true;
|
||
setSavingRoutine(true);
|
||
setRoutineError(null);
|
||
try {
|
||
if (
|
||
(routineDraft.webhookEnabled || routineDraft.githubEnabled) &&
|
||
!active.webhookConfigured &&
|
||
!routineWebhookSecret
|
||
) {
|
||
await ensureWebhookSecret(targetBotId);
|
||
}
|
||
const crons = routineDraft.schedules.map(cronFromPreset);
|
||
let saved: Routine;
|
||
if (targetRoutine) {
|
||
const armOneShot = routineNeedsOneShotArm(targetRoutine, crons);
|
||
let runAt: string | undefined;
|
||
if (armOneShot) {
|
||
if (!routineDraft.runAtLocal) {
|
||
setRoutineError(t`Add a run time for this one-shot.`);
|
||
return;
|
||
}
|
||
const parsed = new Date(routineDraft.runAtLocal);
|
||
if (!Number.isFinite(parsed.getTime()) || parsed.getTime() <= Date.now()) {
|
||
setRoutineError(t`Run time must be in the future.`);
|
||
return;
|
||
}
|
||
runAt = parsed.toISOString();
|
||
}
|
||
saved = await rpc.routines.update({
|
||
routineId: targetRoutine.id,
|
||
name: routineSaveName(targetRoutine, routineDraft.name) || t`Routine`,
|
||
prompt: routineDraft.prompt || t`Check in.`,
|
||
crons,
|
||
active: armOneShot ? true : routineDraft.active,
|
||
webhookEnabled: routineDraft.webhookEnabled,
|
||
githubEnabled: routineDraft.githubEnabled,
|
||
messageProvider: routineDraft.messageProvider,
|
||
...(runAt ? { runAt } : {}),
|
||
});
|
||
} else {
|
||
saved = await rpc.routines.create({
|
||
botId: targetBotId,
|
||
name: routineDraft.name || t`Routine`,
|
||
prompt: routineDraft.prompt || t`Check in.`,
|
||
crons,
|
||
timezone: localTimezone(),
|
||
active: routineDraft.active,
|
||
notify: true,
|
||
webhookEnabled: routineDraft.webhookEnabled,
|
||
githubEnabled: routineDraft.githubEnabled,
|
||
messageProvider: routineDraft.messageProvider,
|
||
});
|
||
}
|
||
if (
|
||
routineSaveRequest.current === saveRequest &&
|
||
activeBotId.current === targetBotId
|
||
) {
|
||
setEditingRoutine(saved);
|
||
setRoutineDraft(draftFromRoutine(saved));
|
||
}
|
||
} catch (error) {
|
||
if (
|
||
routineSaveRequest.current !== saveRequest ||
|
||
activeBotId.current !== targetBotId
|
||
) {
|
||
return;
|
||
}
|
||
setRoutineError(
|
||
error instanceof Error ? error.message : t`Could not save routine`,
|
||
);
|
||
return;
|
||
} finally {
|
||
routineSavePending.current = false;
|
||
setSavingRoutine(false);
|
||
}
|
||
if (
|
||
routineSaveRequest.current !== saveRequest ||
|
||
activeBotId.current !== targetBotId
|
||
) {
|
||
return;
|
||
}
|
||
await refreshThread(targetBotId).catch(() => undefined);
|
||
}}
|
||
onTestRun={async () => {
|
||
if (routineRunPending.current) return;
|
||
const targetBotId = active.id;
|
||
const targetRoutine = editingRoutine;
|
||
if (!targetRoutine) return;
|
||
routineRunPending.current = true;
|
||
setRunningRoutine(true);
|
||
setRoutineError(null);
|
||
try {
|
||
await rpc.routines.testRun({ routineId: targetRoutine.id });
|
||
await refreshThread(targetBotId);
|
||
} catch (error) {
|
||
if (activeBotId.current === targetBotId) {
|
||
setRoutineError(
|
||
error instanceof Error ? error.message : t`Could not run routine`,
|
||
);
|
||
}
|
||
} finally {
|
||
routineRunPending.current = false;
|
||
setRunningRoutine(false);
|
||
}
|
||
}}
|
||
onDelete={() => {
|
||
if (editingRoutine) {
|
||
setDeleteRoutineTarget(editingRoutine);
|
||
return;
|
||
}
|
||
setPanel("computer");
|
||
}}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</aside>
|
||
|
||
<Suspense fallback={null}>
|
||
{contextChat && botMenu ? (
|
||
<BotContextMenu
|
||
bot={contextChat}
|
||
position={botMenu.position}
|
||
onClose={closeBotMenu}
|
||
sections={botSections}
|
||
onTogglePinned={() => {
|
||
setBotMenu(null);
|
||
const request = contextBot
|
||
? rpc.bots.update({ botId: contextBot.id, pinned: !contextBot.pinned })
|
||
: rpc.groups.update({
|
||
groupId: contextGroup!.id,
|
||
pinned: !contextGroup!.pinned,
|
||
});
|
||
void request.then(() => refreshBots());
|
||
}}
|
||
onToggleUnread={() => {
|
||
const unread = !contextChat.unread;
|
||
setBotMenu(null);
|
||
if (contextBot) {
|
||
const request = unread ? markBotUnread(contextBot.id) : markBotRead(contextBot.id);
|
||
void request.catch(() => undefined);
|
||
} else {
|
||
const request = unread
|
||
? rpc.threads.markUnread({ groupId: contextGroup!.id })
|
||
: rpc.threads.markRead({ groupId: contextGroup!.id });
|
||
void request
|
||
.then(() =>
|
||
setGroups((current) =>
|
||
current.map((group) =>
|
||
group.id === contextGroup!.id ? { ...group, unread } : group,
|
||
),
|
||
),
|
||
)
|
||
.catch(() => undefined);
|
||
}
|
||
}}
|
||
onMoveToSection={(sectionId) => {
|
||
setBotMenu(null);
|
||
if (sectionId === contextChat.sectionId) return;
|
||
const request = contextBot
|
||
? rpc.bots.update({ botId: contextBot.id, sectionId })
|
||
: rpc.groups.update({ groupId: contextGroup!.id, sectionId });
|
||
void request.then(() => refreshBots());
|
||
}}
|
||
onCreateSection={() => {
|
||
setNewSectionTarget(
|
||
contextBot
|
||
? { kind: "bot", chat: contextBot }
|
||
: { kind: "group", chat: contextGroup! },
|
||
);
|
||
setBotMenu(null);
|
||
}}
|
||
onRenameSection={(sectionId) => {
|
||
const section = botSections.find((item) => item.id === sectionId);
|
||
const spaceId = bootstrapMe?.spaceId;
|
||
if (section && spaceId) setRenameSectionTarget({ section, spaceId });
|
||
setBotMenu(null);
|
||
}}
|
||
onEdit={() => {
|
||
navigate(contextBot ? `/app/${contextBot.id}` : `/app/g/${contextGroup!.id}`);
|
||
setPanel(contextBot ? "settings" : "group-settings");
|
||
setBotMenu(null);
|
||
}}
|
||
onDuplicate={() => {
|
||
setBotMenu(null);
|
||
const request = contextBot
|
||
? rpc.bots.duplicate({ botId: contextBot.id })
|
||
: rpc.groups.duplicate({ groupId: contextGroup!.id });
|
||
void request.then(async (chat) => {
|
||
await refreshBots();
|
||
navigate(contextBot ? `/app/${chat.id}` : `/app/g/${chat.id}`);
|
||
});
|
||
}}
|
||
onClear={() => {
|
||
setClearTarget(
|
||
contextBot
|
||
? { kind: "bot", chat: contextBot }
|
||
: { kind: "group", chat: contextGroup! },
|
||
);
|
||
setBotMenu(null);
|
||
}}
|
||
onArchive={() => {
|
||
setBotMenu(null);
|
||
const request = contextBot
|
||
? rpc.bots.archive({ botId: contextBot.id })
|
||
: rpc.groups.archive({ groupId: contextGroup!.id });
|
||
void request.then(() => refreshBots(true));
|
||
}}
|
||
onDelete={() => {
|
||
if (contextBot) setDeleteTarget(contextBot);
|
||
else setDeleteGroupTarget(contextGroup!);
|
||
setBotMenu(null);
|
||
}}
|
||
/>
|
||
) : null}
|
||
|
||
{spaceMenu ? (
|
||
<DropdownMenu
|
||
open
|
||
onOpenChange={(open) => {
|
||
if (!open) closeSpaceMenu();
|
||
}}
|
||
>
|
||
{/* Invisible anchor at the pointer position, mirroring the bot menu. */}
|
||
<DropdownMenuTrigger
|
||
render={
|
||
<button
|
||
type="button"
|
||
tabIndex={-1}
|
||
aria-hidden
|
||
className="fixed size-0 p-0 opacity-0"
|
||
style={{ left: spaceMenu.position.x, top: spaceMenu.position.y }}
|
||
/>
|
||
}
|
||
/>
|
||
<DropdownMenuContent
|
||
aria-label={t`Actions for space`}
|
||
align="start"
|
||
sideOffset={0}
|
||
className="w-[220px]"
|
||
>
|
||
<DropdownMenuItem
|
||
variant="destructive"
|
||
onClick={() => {
|
||
const target = spaces.find((space) => space.id === spaceMenu.id);
|
||
if (target) setDeleteSpaceTarget(target);
|
||
setSpaceMenu(null);
|
||
}}
|
||
>
|
||
<Trash2 />
|
||
{t`Delete space`}
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
) : null}
|
||
|
||
{deleteTarget ? (
|
||
<DeleteBotDialog
|
||
bot={deleteTarget}
|
||
onCancel={() => setDeleteTarget(null)}
|
||
onConfirm={async (deleteMemories) => {
|
||
await rpc.bots.remove({ botId: deleteTarget.id, deleteMemories });
|
||
setDeleteTarget(null);
|
||
setPanel(null);
|
||
await refreshBots(true);
|
||
}}
|
||
/>
|
||
) : null}
|
||
|
||
{deleteGroupTarget ? (
|
||
<DeleteItemDialog
|
||
item={deleteGroupTarget}
|
||
noun="group"
|
||
onCancel={() => setDeleteGroupTarget(null)}
|
||
onConfirm={async () => {
|
||
await rpc.groups.remove({ groupId: deleteGroupTarget.id });
|
||
setDeleteGroupTarget(null);
|
||
setPanel(null);
|
||
await refreshBots(true);
|
||
}}
|
||
/>
|
||
) : null}
|
||
|
||
{deleteSpaceTarget ? (
|
||
<DeleteItemDialog
|
||
item={deleteSpaceTarget}
|
||
noun="space"
|
||
description={
|
||
<Trans>Only empty spaces can be deleted. Delete its bots and groups first.</Trans>
|
||
}
|
||
onCancel={() => setDeleteSpaceTarget(null)}
|
||
onConfirm={async () => {
|
||
const targetId = deleteSpaceTarget.id;
|
||
const result = await rpc.spaces.remove({ spaceId: targetId });
|
||
setDeleteSpaceTarget(null);
|
||
setPanel(null);
|
||
const effectiveSpaceId = selectedSpaceId() ?? bootstrapMe?.spaceId;
|
||
if (effectiveSpaceId === targetId) {
|
||
// The auth boundary changed, so reload like a space switch.
|
||
if (selectSpace(result.activeSpaceId)) {
|
||
window.location.assign("/app");
|
||
return;
|
||
}
|
||
}
|
||
await refreshBots(true);
|
||
}}
|
||
/>
|
||
) : null}
|
||
|
||
{newSectionTarget ? (
|
||
<NewBotSectionDialog
|
||
bot={newSectionTarget.chat}
|
||
onCancel={() => setNewSectionTarget(null)}
|
||
onConfirm={async (name) => {
|
||
await rpc.botSections.create(
|
||
newSectionTarget.kind === "bot"
|
||
? { botId: newSectionTarget.chat.id, name }
|
||
: { groupId: newSectionTarget.chat.id, name },
|
||
);
|
||
setNewSectionTarget(null);
|
||
await refreshBots();
|
||
}}
|
||
/>
|
||
) : null}
|
||
|
||
{renameSectionTarget ? (
|
||
<RenameBotSectionDialog
|
||
section={renameSectionTarget.section}
|
||
onCancel={() => setRenameSectionTarget(null)}
|
||
onConfirm={async (name) => {
|
||
await rpc.botSections.update(
|
||
{
|
||
sectionId: renameSectionTarget.section.id,
|
||
name,
|
||
},
|
||
{ context: { spaceId: renameSectionTarget.spaceId } },
|
||
);
|
||
setRenameSectionTarget(null);
|
||
await refreshBots();
|
||
}}
|
||
/>
|
||
) : null}
|
||
|
||
{sectionMenu ? (
|
||
<DropdownMenu
|
||
open
|
||
onOpenChange={(open) => {
|
||
if (!open) closeSectionMenu();
|
||
}}
|
||
>
|
||
<DropdownMenuTrigger
|
||
render={
|
||
<button
|
||
type="button"
|
||
tabIndex={-1}
|
||
aria-hidden
|
||
className="fixed size-0 p-0 opacity-0"
|
||
style={{ left: sectionMenu.position.x, top: sectionMenu.position.y }}
|
||
/>
|
||
}
|
||
/>
|
||
<DropdownMenuContent
|
||
aria-label={t`Actions for ${sectionMenu.section.name}`}
|
||
align="start"
|
||
sideOffset={0}
|
||
className="w-[220px]"
|
||
>
|
||
<DropdownMenuItem
|
||
onClick={() => {
|
||
setRenameSectionTarget({
|
||
section: sectionMenu.section,
|
||
spaceId: sectionMenu.spaceId,
|
||
});
|
||
setSectionMenu(null);
|
||
}}
|
||
>
|
||
<Pencil />
|
||
{t`Rename section`}
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
) : null}
|
||
|
||
<CommandPalette
|
||
open={commandPaletteOpen}
|
||
onOpenChange={setCommandPaletteOpen}
|
||
bots={bots}
|
||
onSelectBot={(id) => {
|
||
setMobileSidebarOpen(false);
|
||
navigate(`/app/${id}`);
|
||
}}
|
||
/>
|
||
|
||
{newSpaceOpen ? (
|
||
<NewSpaceDialog
|
||
variant={newSpaceDialogVariant}
|
||
canLinkFolder={bootstrapMe?.isDeploymentOwner === true}
|
||
onCancel={() => setNewSpaceOpen(false)}
|
||
onConfirm={async ({ name, workspacePath }) => {
|
||
const space = await rpc.spaces.create({
|
||
name,
|
||
...(workspacePath ? { workspacePath } : {}),
|
||
});
|
||
setNewSpaceOpen(false);
|
||
if (!selectSpace(space.id)) {
|
||
await refreshBots();
|
||
return;
|
||
}
|
||
window.location.assign("/app");
|
||
}}
|
||
/>
|
||
) : null}
|
||
|
||
{pickerInfoTopic ? (
|
||
<PickerInfoDialog topic={pickerInfoTopic} onClose={() => setPickerInfoTopic(null)} />
|
||
) : null}
|
||
|
||
{clearTarget ? (
|
||
<ClearConversationDialog
|
||
bot={clearTarget.chat}
|
||
onCancel={() => setClearTarget(null)}
|
||
onConfirm={async () => {
|
||
await rpc.threads.clear(
|
||
clearTarget.kind === "bot"
|
||
? { botId: clearTarget.chat.id }
|
||
: { groupId: clearTarget.chat.id },
|
||
);
|
||
if (
|
||
(clearTarget.kind === "bot" && active?.id === clearTarget.chat.id) ||
|
||
(clearTarget.kind === "group" && activeGroup?.id === clearTarget.chat.id)
|
||
) {
|
||
expandedHistoryThread.current = null;
|
||
pinnedAroundRef.current = null;
|
||
historyEpoch.current += 1;
|
||
updateSnapshot((current) =>
|
||
current ? { ...current, messages: [], olderCursor: null, run: null } : current,
|
||
);
|
||
}
|
||
setClearTarget(null);
|
||
await refreshBots();
|
||
}}
|
||
/>
|
||
) : null}
|
||
|
||
{deleteRoutineTarget ? (
|
||
<DeleteItemDialog
|
||
item={deleteRoutineTarget}
|
||
noun="routine"
|
||
onCancel={() => setDeleteRoutineTarget(null)}
|
||
onConfirm={async () => {
|
||
const target = deleteRoutineTarget;
|
||
await rpc.routines.remove({ routineId: target.id });
|
||
setDeleteRoutineTarget(null);
|
||
setEditingRoutine((current) => (current?.id === target.id ? null : current));
|
||
if (activeBotId.current !== target.botId) return;
|
||
await refreshThread(target.botId);
|
||
if (activeBotId.current === target.botId) setPanel("computer");
|
||
}}
|
||
/>
|
||
) : null}
|
||
|
||
{pluginsOpen ? (
|
||
<PluginsOverlay
|
||
activeBotId={activeBotId.current}
|
||
onClose={() => setPluginsOpen(false)}
|
||
onOpenMcp={() => {
|
||
setPluginsOpen(false);
|
||
setMcpOpen(true);
|
||
}}
|
||
/>
|
||
) : null}
|
||
{mcpOpen ? <McpServersOverlay onClose={() => setMcpOpen(false)} /> : null}
|
||
{messagingSettingsOpen ? (
|
||
<MessagingSettingsOverlay onClose={() => setMessagingSettingsOpen(false)} />
|
||
) : null}
|
||
</Suspense>
|
||
|
||
<Suspense fallback={null}>
|
||
{settingsOpen ? (
|
||
<SettingsOverlay
|
||
name={userName}
|
||
email={session.data?.user.email}
|
||
usage={usage}
|
||
initialSection={settingsSection}
|
||
avatarStyle={bootstrapMe?.avatarStyle ?? "robot"}
|
||
isDeploymentOwner={bootstrapMe?.isDeploymentOwner === true}
|
||
sandboxProvider={bootstrapMe?.sandboxProvider}
|
||
messagingEnabled={messagingSurfaceEnabled}
|
||
onOpenMessaging={() => {
|
||
setSettingsOpen(false);
|
||
setMessagingSettingsOpen(true);
|
||
}}
|
||
onAvatarStyleChange={async (avatarStyle) => {
|
||
const nextMe = await rpc.preferences.update({ avatarStyle });
|
||
setBootstrapMe(nextMe);
|
||
}}
|
||
memoryConfig={memoryProviderConfig}
|
||
onMemoryConfigChange={(config) => {
|
||
memoryProviderConfigRevision.current += 1;
|
||
setMemoryProviderConfig(config);
|
||
}}
|
||
onVoiceStatusMaybeChanged={async () => {
|
||
try {
|
||
setVoiceStatus(
|
||
await Promise.race([rpc.voice.status(), voiceStatusRefreshTimeout()]),
|
||
);
|
||
} catch {
|
||
// Prefer reopening Voice settings over CallView with stale readiness.
|
||
setVoiceStatus(null);
|
||
}
|
||
}}
|
||
activeBotId={activeBotId.current}
|
||
onOpenMcp={() => {
|
||
setSettingsOpen(false);
|
||
setMcpOpen(true);
|
||
}}
|
||
onClose={() => {
|
||
setSettingsOpen(false);
|
||
setSettingsSection("general");
|
||
}}
|
||
/>
|
||
) : null}
|
||
{peerConversation && active ? (
|
||
<PeerMessagesOverlay
|
||
botId={active.id}
|
||
botName={active.name}
|
||
botColor={active.color}
|
||
peerBotId={peerConversation.peerBotId}
|
||
peerBotName={peerConversation.peerBotName}
|
||
peerBotColor={
|
||
resolveTranscriptBot(peerConversation.peerBotId)?.color ?? FALLBACK_BOT_COLOR
|
||
}
|
||
onClose={() => setPeerConversation(null)}
|
||
/>
|
||
) : null}
|
||
{callOpen && voiceCallBot ? (
|
||
<CallView
|
||
botId={voiceCallBot.id}
|
||
botName={voiceCallBot.name}
|
||
botTitle={voiceCallBot.title}
|
||
botDescription={voiceCallBot.description}
|
||
botColor={voiceCallBot.color}
|
||
botStatus={voiceCallBot.status}
|
||
transcribe={Boolean(voiceStatus?.transcribe)}
|
||
snapshot={activeSnapshot}
|
||
onSend={sendMessage}
|
||
onFollowUp={followUpMessage}
|
||
onAnswer={answerMessage}
|
||
onClose={closeVoiceStage}
|
||
/>
|
||
) : null}
|
||
</Suspense>
|
||
|
||
{booting ? (
|
||
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center gap-[22px] bg-background/95">
|
||
<div className="text-[19px] font-medium text-foreground">
|
||
<Trans>Booting up {active?.name}’s computer</Trans>
|
||
</div>
|
||
<div className="h-[5px] w-[min(420px,70%)] overflow-hidden rounded-full bg-accent">
|
||
<div className="h-full w-2/3 rounded-full bg-primary" />
|
||
</div>
|
||
</div>
|
||
) : computerOpen && active ? (
|
||
<div className="fixed inset-0 z-30 bg-background">
|
||
<div
|
||
data-testid="computer-viewport"
|
||
className="fixed inset-x-0 top-0 flex flex-col bg-background"
|
||
style={{
|
||
height: computerViewport ? `${computerViewport.height}px` : "100dvh",
|
||
top: computerViewport ? `${computerViewport.offsetTop}px` : undefined,
|
||
}}
|
||
>
|
||
<div
|
||
data-testid="computer-chrome"
|
||
className="flex items-center justify-between gap-4 border-b border-sidebar-border px-[18px] py-3.5"
|
||
>
|
||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||
<BotAvatar
|
||
color={active.color}
|
||
identity={active.id}
|
||
size={28}
|
||
status={active.status}
|
||
/>
|
||
{recordingSkill ? (
|
||
<TeachRecordingChrome
|
||
recording={recordingSkill}
|
||
busy={teachBusy}
|
||
onStop={stopTeaching}
|
||
variant="overlay"
|
||
/>
|
||
) : (
|
||
<span className="truncate text-ui-body font-medium text-foreground" dir="auto">
|
||
{computerLabel(computer?.mode, active.name)}
|
||
</span>
|
||
)}
|
||
{!recordingSkill && hasControl ? (
|
||
computer?.takeoverRequested ? (
|
||
<span className="rounded-full bg-warning/15 px-[11px] py-1 text-ui-body-sm text-warning">
|
||
<Trans>Needs you</Trans>
|
||
</span>
|
||
) : (
|
||
<span className="rounded-full bg-success/15 px-[11px] py-1 text-ui-body-sm text-success">
|
||
<Trans>You have control</Trans>
|
||
</span>
|
||
)
|
||
) : null}
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
{composerRunning ? (
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
aria-label={t`Stop`}
|
||
data-testid="computer-overlay-stop"
|
||
onClick={() => void stopRun()}
|
||
disabled={sending}
|
||
>
|
||
<Trans>Stop</Trans>
|
||
</Button>
|
||
) : null}
|
||
{recordingSkill ? (
|
||
<TeachStopButton busy={teachBusy} onStop={stopTeaching} />
|
||
) : hasControl ? (
|
||
<ComputerReleaseActions
|
||
takeoverRequested={Boolean(computer?.takeoverRequested)}
|
||
onRelease={releaseComputer}
|
||
/>
|
||
) : null}
|
||
{active && !recordingSkill ? (
|
||
<TeachComputerOverlayControl
|
||
key={active.id}
|
||
botId={active.id}
|
||
computer={computer}
|
||
busy={teachBusy}
|
||
onRefresh={refreshActiveTeaching}
|
||
/>
|
||
) : null}
|
||
{active && !recordingSkill ? (
|
||
<ComputerMaintenanceActions
|
||
botId={active.id}
|
||
computer={computer}
|
||
onChanged={async () => {
|
||
await refreshThread(active.id);
|
||
}}
|
||
/>
|
||
) : null}
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
className="text-muted-foreground"
|
||
aria-label={t`Close computer`}
|
||
onClick={() => setComputerOpen(false)}
|
||
>
|
||
<X strokeWidth={1.8} className="size-icon-md" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
{sendError ? (
|
||
<div
|
||
role="alert"
|
||
className="border-b border-destructive/40 bg-destructive/10 px-[18px] py-2 text-ui-body-sm text-destructive"
|
||
>
|
||
{sendError}
|
||
</div>
|
||
) : null}
|
||
<div className="relative min-h-0 flex-1 bg-background">
|
||
{computer?.kind === "desktop" ? (
|
||
<DesktopKindEmptyState className="grid h-full place-items-center px-8 text-center text-sm text-muted-foreground/80" />
|
||
) : computer?.state === "running" && embeddedScreenUrl && !computerScreenError ? (
|
||
<>
|
||
<iframe
|
||
title={t`Bot screen`}
|
||
src={embeddedScreenUrl}
|
||
sandbox={screenIframeSandbox(embeddedScreenUrl)}
|
||
className="h-full w-full border-0 bg-black"
|
||
allow="clipboard-read; clipboard-write; fullscreen"
|
||
style={{
|
||
pointerEvents: recordingSkill || !hasControl ? "none" : "auto",
|
||
}}
|
||
/>
|
||
{active && !hasControl ? (
|
||
<AgentCursor botId={active.id} color={active.color} name={active.name} />
|
||
) : null}
|
||
{active ? (
|
||
<TeachCaptureOverlay
|
||
botId={active.id}
|
||
skill={recordingSkill}
|
||
enabled={Boolean(recordingSkill)}
|
||
screenWidth={computer?.screenWidth}
|
||
screenHeight={computer?.screenHeight}
|
||
/>
|
||
) : null}
|
||
</>
|
||
) : (
|
||
<div className="grid h-full place-items-center text-sm text-muted-foreground/80">
|
||
{computerScreenError ??
|
||
(computer?.state === "suspended"
|
||
? t`Computer is asleep`
|
||
: computerLabel(computer?.mode, active.name))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
|
||
return <AvatarStyleProvider value="organic">{shell}</AvatarStyleProvider>;
|
||
}
|
||
|
||
const Transcript = memo(function Transcript({
|
||
scrollRef,
|
||
artifactTarget,
|
||
onOpenArtifact,
|
||
messages,
|
||
olderCursor,
|
||
loadingOlder,
|
||
answerableAskMessageId,
|
||
running,
|
||
workingBots,
|
||
onLoadOlder,
|
||
onOpenBot,
|
||
onOpenGroup,
|
||
onAnswer,
|
||
onReply,
|
||
onQuote,
|
||
onReact,
|
||
onJumpToMessage,
|
||
onOpenPeerMessages,
|
||
memberName,
|
||
peerBot,
|
||
onRefresh,
|
||
onBotChanged,
|
||
onAddRoutine,
|
||
voiceReady,
|
||
speakingMessageId,
|
||
onSpeak,
|
||
activeName,
|
||
encounterMode = false,
|
||
voiceEncounterActive = false,
|
||
}: {
|
||
scrollRef: RefObject<HTMLDivElement | null>;
|
||
artifactTarget: ArtifactTarget;
|
||
onOpenArtifact?: (artifact: WorkspaceArtifact) => void;
|
||
messages: ThreadMessage[];
|
||
olderCursor: number | null;
|
||
loadingOlder: boolean;
|
||
answerableAskMessageId: string | null;
|
||
running: boolean;
|
||
workingBots: GroupAvatarMember[];
|
||
onLoadOlder: () => void | Promise<void>;
|
||
onOpenBot: (botId: string) => void;
|
||
onOpenGroup: (groupId: string) => void;
|
||
onAnswer: (message: ThreadMessage, text: string) => Promise<void>;
|
||
onReply: (message: ThreadMessage) => void;
|
||
onQuote: (message: ThreadMessage, quote: string) => void;
|
||
onReact: (message: ThreadMessage, reaction: MessageReaction) => Promise<void>;
|
||
onJumpToMessage: (messageId: string) => void;
|
||
onOpenPeerMessages: (peer: { peerBotId: string; peerBotName: string }) => void;
|
||
memberName?: (botId: string | undefined) => string | undefined;
|
||
peerBot: (botId: string) => { color: string; status?: string } | undefined;
|
||
onRefresh: () => Promise<void>;
|
||
onBotChanged: () => Promise<void>;
|
||
onAddRoutine: (name: string, prompt: string) => void;
|
||
voiceReady: boolean;
|
||
speakingMessageId: string | null;
|
||
onSpeak: (message: ThreadMessage) => void;
|
||
activeName?: string;
|
||
encounterMode?: boolean;
|
||
voiceEncounterActive?: boolean;
|
||
}) {
|
||
const { t } = useLingui();
|
||
const [atEnd, setAtEnd] = useState(true);
|
||
const following = useRef(true);
|
||
const autoScrolling = useRef(false);
|
||
const lastScrollTop = useRef<number | null>(null);
|
||
const autoScrollTimer = useRef<number | undefined>(undefined);
|
||
const jumpButtonRef = useRef<HTMLButtonElement>(null);
|
||
const messageById = useMemo(
|
||
() => new Map(messages.map((message) => [message.id, message])),
|
||
[messages],
|
||
);
|
||
const reactionView = useMemo(
|
||
() => projectMessageReactions(messages.map(withIntakeNoteText)),
|
||
// Onboarding notes are worded in the UI language.
|
||
[messages, i18n.locale],
|
||
);
|
||
const transcriptSubordinate = encounterMode && voiceEncounterActive;
|
||
const workingBotName = workingBots.length === 1 ? workingBots[0]?.name : undefined;
|
||
const workingLabel =
|
||
workingBotName != null && workingBotName !== ""
|
||
? t`${workingBotName} is working`
|
||
: t`Bots are working`;
|
||
const [quoteDraft, setQuoteDraft] = useState<{
|
||
message: ThreadMessage;
|
||
text: string;
|
||
range: Range;
|
||
} | null>(null);
|
||
const selectingWithMouse = useRef(false);
|
||
|
||
const evaluateSelection = useCallback(() => {
|
||
const selection = window.getSelection();
|
||
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
|
||
setQuoteDraft(null);
|
||
return;
|
||
}
|
||
const range = selection.getRangeAt(0);
|
||
const rowOf = (node: Node) =>
|
||
(node instanceof Element ? node : node.parentElement)?.closest<HTMLElement>(
|
||
"[data-message-id]",
|
||
) ?? null;
|
||
const draft = quoteDraftForSelection(
|
||
{
|
||
startRow: rowOf(range.startContainer),
|
||
endRow: rowOf(range.endContainer),
|
||
text: selection.toString(),
|
||
},
|
||
messageById,
|
||
);
|
||
setQuoteDraft(draft ? { ...draft, range } : null);
|
||
}, [messageById]);
|
||
|
||
// Keyboard and assistive-tech selections never reach a mouseup, so the pill
|
||
// lifecycle listens on selectionchange; the mouse flag keeps it hidden while
|
||
// a drag is still in flight.
|
||
useEffect(() => {
|
||
const onMouseDown = (event: MouseEvent) => {
|
||
selectingWithMouse.current = true;
|
||
if ((event.target as Element | null)?.closest?.("[data-quote-selection]")) return;
|
||
setQuoteDraft(null);
|
||
};
|
||
const onMouseUp = () => {
|
||
selectingWithMouse.current = false;
|
||
evaluateSelection();
|
||
};
|
||
const onSelectionChange = () => {
|
||
if (!selectingWithMouse.current) evaluateSelection();
|
||
};
|
||
const onKey = (event: KeyboardEvent) => {
|
||
if (event.key === "Escape") setQuoteDraft(null);
|
||
};
|
||
document.addEventListener("mousedown", onMouseDown, true);
|
||
document.addEventListener("mouseup", onMouseUp, true);
|
||
document.addEventListener("selectionchange", onSelectionChange);
|
||
document.addEventListener("keydown", onKey);
|
||
return () => {
|
||
document.removeEventListener("mousedown", onMouseDown, true);
|
||
document.removeEventListener("mouseup", onMouseUp, true);
|
||
document.removeEventListener("selectionchange", onSelectionChange);
|
||
document.removeEventListener("keydown", onKey);
|
||
};
|
||
}, [evaluateSelection]);
|
||
const snapToEnd = useCallback(() => {
|
||
const element = scrollRef.current;
|
||
if (!element) return;
|
||
following.current = true;
|
||
autoScrolling.current = false;
|
||
setAtEnd(true);
|
||
element.scrollTo({ top: element.scrollHeight, behavior: "auto" });
|
||
}, [scrollRef]);
|
||
|
||
const jumpToLatest = useCallback(() => {
|
||
const element = scrollRef.current;
|
||
if (!element) return;
|
||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||
following.current = true;
|
||
autoScrolling.current = !reducedMotion;
|
||
setAtEnd(true);
|
||
element.scrollTo({
|
||
top: element.scrollHeight,
|
||
behavior: reducedMotion ? "auto" : "smooth",
|
||
});
|
||
window.clearTimeout(autoScrollTimer.current);
|
||
// Fallback only: onScroll clears autoScrolling once near-end is reached.
|
||
autoScrollTimer.current = window.setTimeout(
|
||
() => {
|
||
autoScrolling.current = false;
|
||
},
|
||
reducedMotion ? 0 : 2_000,
|
||
);
|
||
}, [scrollRef]);
|
||
|
||
useLayoutEffect(() => {
|
||
if (following.current) snapToEnd();
|
||
}, [messages, running, snapToEnd]);
|
||
|
||
// A question the bot waits on can scroll out of view; offer a way back to it instead of the arrow.
|
||
const [askOffscreen, setAskOffscreen] = useState(false);
|
||
const findAsk = useCallback(
|
||
() =>
|
||
answerableAskMessageId
|
||
? (scrollRef.current?.querySelector<HTMLElement>(
|
||
`[data-message-id="${CSS.escape(answerableAskMessageId)}"]`,
|
||
) ?? null)
|
||
: null,
|
||
[answerableAskMessageId, scrollRef],
|
||
);
|
||
useEffect(() => {
|
||
const root = scrollRef.current;
|
||
const element = findAsk();
|
||
if (!root || !element || typeof IntersectionObserver === "undefined") {
|
||
setAskOffscreen(false);
|
||
return;
|
||
}
|
||
const observer = new IntersectionObserver(
|
||
(entries) => setAskOffscreen(!entries.some((entry) => entry.isIntersecting)),
|
||
{ root, threshold: 0.2 },
|
||
);
|
||
observer.observe(element);
|
||
return () => observer.disconnect();
|
||
}, [findAsk, messages, scrollRef]);
|
||
const jumpToAsk = useCallback(() => {
|
||
const element = findAsk();
|
||
if (!element) return;
|
||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||
following.current = false;
|
||
element.scrollIntoView({ behavior: reducedMotion ? "auto" : "smooth", block: "center" });
|
||
element
|
||
.querySelector<HTMLElement>("[data-ask-card] input, [data-ask-card] button")
|
||
?.focus({ preventScroll: true });
|
||
}, [findAsk]);
|
||
const showJumpToLatest = !atEnd && !askOffscreen;
|
||
|
||
useLayoutEffect(() => {
|
||
const button = jumpButtonRef.current;
|
||
if (atEnd && button && document.activeElement === button) {
|
||
button.blur();
|
||
}
|
||
}, [atEnd]);
|
||
|
||
const loadOlder = useCallback(() => {
|
||
const wasFollowing = following.current;
|
||
// Prepend must not race the messages-driven snap-to-end follow path.
|
||
following.current = false;
|
||
autoScrolling.current = false;
|
||
const pending = onLoadOlder();
|
||
if (!pending) return;
|
||
return Promise.resolve(pending).catch((error) => {
|
||
const element = scrollRef.current;
|
||
if (wasFollowing && element && transcriptIsNearEnd(element)) {
|
||
following.current = true;
|
||
setAtEnd(true);
|
||
}
|
||
throw error;
|
||
});
|
||
}, [onLoadOlder, scrollRef]);
|
||
|
||
useEffect(
|
||
() => () => {
|
||
window.clearTimeout(autoScrollTimer.current);
|
||
},
|
||
[],
|
||
);
|
||
|
||
return (
|
||
// Clip the scroller's padding when the column leaves no room, e.g. beside the main inbox.
|
||
<div className="relative flex min-h-0 flex-1 overflow-hidden">
|
||
<div
|
||
ref={scrollRef}
|
||
data-testid="transcript"
|
||
onPointerDown={(event) => {
|
||
lastScrollTop.current = event.currentTarget.scrollTop;
|
||
autoScrolling.current = false;
|
||
following.current = false;
|
||
}}
|
||
onTouchStart={(event) => {
|
||
lastScrollTop.current = event.currentTarget.scrollTop;
|
||
autoScrolling.current = false;
|
||
following.current = false;
|
||
}}
|
||
onWheel={(event) => {
|
||
if (event.deltaY < 0) {
|
||
lastScrollTop.current = event.currentTarget.scrollTop;
|
||
autoScrolling.current = false;
|
||
following.current = false;
|
||
}
|
||
}}
|
||
onScroll={(event) => {
|
||
const scrolledDown = transcriptMovedDown(
|
||
lastScrollTop.current,
|
||
event.currentTarget.scrollTop,
|
||
);
|
||
lastScrollTop.current = event.currentTarget.scrollTop;
|
||
const nearEnd = transcriptIsNearEnd(event.currentTarget);
|
||
setAtEnd(nearEnd);
|
||
if (nearEnd) {
|
||
if (scrolledDown) following.current = true;
|
||
if (autoScrolling.current) {
|
||
autoScrolling.current = false;
|
||
window.clearTimeout(autoScrollTimer.current);
|
||
}
|
||
} else if (!autoScrolling.current) {
|
||
following.current = false;
|
||
}
|
||
}}
|
||
className={`rk-scroll flex min-h-0 flex-1 flex-col overflow-y-auto transition-opacity duration-300 ${
|
||
encounterMode ? "gap-2 px-3 py-3 md:px-6 md:py-4" : "gap-3.5 px-3.5 py-4 md:px-8 md:py-6"
|
||
} ${transcriptSubordinate ? "opacity-75" : ""}`}
|
||
>
|
||
{encounterMode ? (
|
||
<p className="mx-auto w-full max-w-2xl px-1 text-ui-label font-medium uppercase tracking-wider text-muted-foreground/80">
|
||
{voiceEncounterActive ? <Trans>Transcript</Trans> : <Trans>Conversation record</Trans>}
|
||
</p>
|
||
) : null}
|
||
{olderCursor != null ? (
|
||
<button
|
||
type="button"
|
||
disabled={loadingOlder}
|
||
onClick={() => void loadOlder()}
|
||
className="self-center rounded-lg px-3 py-1.5 text-ui-body-sm text-muted-foreground hover:bg-muted hover:text-foreground/75 disabled:opacity-50"
|
||
>
|
||
{loadingOlder ? t`Loading…` : t`Load earlier messages`}
|
||
</button>
|
||
) : null}
|
||
<div
|
||
className={`mx-auto flex w-full flex-1 flex-col pb-6 ${
|
||
encounterMode ? "max-w-2xl gap-3" : "max-w-4xl gap-6 pb-8"
|
||
}`}
|
||
>
|
||
{reactionView.visibleMessages.length === 0 && encounterMode ? (
|
||
<p className="py-2 text-ui-body-sm text-muted-foreground">
|
||
{activeName
|
||
? t`Start the conversation — ${activeName} is on stage above.`
|
||
: t`Start the conversation.`}
|
||
</p>
|
||
) : null}
|
||
{reactionView.visibleMessages.map((message) => {
|
||
if (!message.blocks.some((block) => !isToolActivityBlock(block))) return null;
|
||
const peerReceipt = isPeerReceiptBlocks(message.blocks);
|
||
const messageReactions = reactionView.reactions.get(message.id);
|
||
return (
|
||
<div
|
||
key={message.id}
|
||
data-message-id={message.id}
|
||
// Only persisted messages can be reply targets; synthetic rows
|
||
// (progress:, subagent:) carry a `prefix:` id.
|
||
data-quotable={message.id.includes(":") ? undefined : ""}
|
||
className={peerReceipt ? "relative py-0.5" : "group/message relative hover:z-20"}
|
||
>
|
||
{!peerReceipt && !message.id.startsWith("progress:") ? (
|
||
<time
|
||
dateTime={message.createdAt}
|
||
data-testid="message-hover-time"
|
||
className={cn(
|
||
"pointer-events-none absolute top-1 z-10 text-ui-label tabular-nums text-muted-foreground opacity-0 transition-opacity group-hover/message:opacity-100 group-focus-within/message:opacity-100 group-has-[[aria-expanded=true]]/message:opacity-100",
|
||
message.role === "user" ? "start-0" : "end-0",
|
||
)}
|
||
>
|
||
{new Date(message.createdAt).toLocaleTimeString(i18n.locale || "en", {
|
||
hour: "numeric",
|
||
minute: "2-digit",
|
||
})}
|
||
</time>
|
||
) : null}
|
||
<div
|
||
className={
|
||
peerReceipt
|
||
? undefined
|
||
: `relative flex ${message.role === "user" ? "justify-end" : "justify-start"}`
|
||
}
|
||
>
|
||
<div
|
||
data-testid={peerReceipt ? undefined : "message-bubble-frame"}
|
||
className={
|
||
peerReceipt
|
||
? undefined
|
||
: `relative min-w-0 ${
|
||
message.role === "user"
|
||
? encounterMode
|
||
? "w-fit max-w-[92%] md:max-w-[min(78%,calc(100%_-_3rem))]"
|
||
: "w-fit max-w-[88%] md:max-w-[min(72%,calc(100%_-_5rem))]"
|
||
: "w-full max-w-full"
|
||
}`
|
||
}
|
||
>
|
||
{peerReceipt ? null : (
|
||
<MessageHoverActions
|
||
message={message}
|
||
side={message.role === "user" ? "start" : "end"}
|
||
onReply={onReply}
|
||
onReact={onReact}
|
||
/>
|
||
)}
|
||
<MessageView
|
||
artifactTarget={artifactTarget}
|
||
onOpenArtifact={onOpenArtifact}
|
||
message={message}
|
||
canAnswer={message.id === answerableAskMessageId}
|
||
onOpenBot={onOpenBot}
|
||
onOpenGroup={onOpenGroup}
|
||
onOpenPeerMessages={onOpenPeerMessages}
|
||
onAnswer={onAnswer}
|
||
encounterMode={encounterMode}
|
||
speakerName={
|
||
peerReceipt
|
||
? undefined
|
||
: message.role === "bot"
|
||
? memberName?.(message.botId)
|
||
: undefined
|
||
}
|
||
memberName={memberName}
|
||
peerBot={peerBot}
|
||
replyPreview={
|
||
message.replyToMessageId
|
||
? messageById.get(message.replyToMessageId)
|
||
: undefined
|
||
}
|
||
replyToMessageId={message.replyToMessageId}
|
||
onJumpToMessage={onJumpToMessage}
|
||
onRefresh={onRefresh}
|
||
onBotChanged={onBotChanged}
|
||
onAddRoutine={onAddRoutine}
|
||
voiceReady={voiceReady}
|
||
speaking={speakingMessageId === message.id}
|
||
onSpeak={() => onSpeak(message)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
{!peerReceipt && messageReactions ? (
|
||
<div
|
||
data-testid="message-reactions"
|
||
className={cn(
|
||
"mt-1 flex flex-wrap gap-1",
|
||
message.role === "user" && "justify-end",
|
||
)}
|
||
>
|
||
{[...messageReactions].map(([emoji, count]) => (
|
||
<span
|
||
key={emoji}
|
||
className="rounded-full border border-border bg-muted px-2 py-0.5 text-ui-label"
|
||
>
|
||
{emoji}
|
||
{count > 1 ? ` ${count}` : ""}
|
||
</span>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})}
|
||
{running &&
|
||
!encounterMode &&
|
||
!messages.some(
|
||
(message) =>
|
||
message.id.startsWith("progress:") &&
|
||
message.blocks.some(
|
||
(block) =>
|
||
block.kind === "progress" && !isToolActivityBlock(block) && Boolean(block.text),
|
||
),
|
||
) ? (
|
||
<ActiveBotGlyph bots={workingBots} label={workingLabel} />
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
{quoteDraft ? (
|
||
<QuoteSelectionButton
|
||
range={quoteDraft.range}
|
||
onQuote={() => {
|
||
onQuote(quoteDraft.message, quoteDraft.text);
|
||
window.getSelection()?.removeAllRanges();
|
||
setQuoteDraft(null);
|
||
}}
|
||
/>
|
||
) : null}
|
||
<button
|
||
ref={jumpButtonRef}
|
||
type="button"
|
||
aria-label={t`Jump to latest`}
|
||
aria-hidden={!showJumpToLatest}
|
||
tabIndex={showJumpToLatest ? 0 : -1}
|
||
onClick={jumpToLatest}
|
||
className={`absolute bottom-4 left-1/2 z-20 grid h-9 w-9 -translate-x-1/2 place-items-center rounded-full border border-border bg-muted/95 text-foreground/75 shadow-md backdrop-blur transition-[opacity,transform,background-color] duration-200 ease-[cubic-bezier(.22,1,.36,1)] hover:bg-border motion-reduce:transition-none ${
|
||
showJumpToLatest
|
||
? "translate-y-0 opacity-100"
|
||
: "pointer-events-none translate-y-2 opacity-0"
|
||
}`}
|
||
>
|
||
<ArrowDown size={17} strokeWidth={1.8} />
|
||
</button>
|
||
{askOffscreen ? (
|
||
<button
|
||
type="button"
|
||
data-testid="needs-you-jump"
|
||
onClick={jumpToAsk}
|
||
className="absolute bottom-4 left-1/2 z-20 inline-flex h-9 -translate-x-1/2 items-center gap-1.5 rounded-full border border-border bg-muted/95 px-3.5 text-ui-body-sm font-medium text-foreground shadow-md backdrop-blur hover:bg-border"
|
||
>
|
||
<span aria-hidden="true" className="size-2 rounded-full bg-warning" />
|
||
{t`Needs you`}
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
);
|
||
});
|
||
|
||
/**
|
||
* Floating Quote action anchored to the selection's bounding rect. Measures
|
||
* itself after mount so it can flip below the selection when there is no room
|
||
* above and stay clamped inside the viewport; re-anchors on scroll/resize.
|
||
*/
|
||
const QuoteSelectionButton = memo(function QuoteSelectionButton({
|
||
range,
|
||
onQuote,
|
||
}: {
|
||
range: Range;
|
||
onQuote: () => void;
|
||
}) {
|
||
const { t } = useLingui();
|
||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||
const [placement, setPlacement] = useState<{
|
||
top: number;
|
||
left: number;
|
||
above: boolean;
|
||
} | null>(null);
|
||
|
||
useLayoutEffect(() => {
|
||
const update = () => {
|
||
if (range.collapsed || !document.contains(range.commonAncestorContainer)) {
|
||
setPlacement(null);
|
||
return;
|
||
}
|
||
const rect = range.getBoundingClientRect();
|
||
const width = buttonRef.current?.offsetWidth ?? 0;
|
||
const height = buttonRef.current?.offsetHeight ?? 0;
|
||
const above = rect.top >= height + 8;
|
||
setPlacement({
|
||
top: above ? rect.top - 8 : rect.bottom + 8,
|
||
left: Math.min(
|
||
Math.max(rect.left + rect.width / 2, width / 2 + 8),
|
||
window.innerWidth - width / 2 - 8,
|
||
),
|
||
above,
|
||
});
|
||
};
|
||
update();
|
||
window.addEventListener("resize", update);
|
||
// Scroll doesn't bubble — listen on the capture phase to catch any scroller.
|
||
window.addEventListener("scroll", update, { capture: true, passive: true });
|
||
return () => {
|
||
window.removeEventListener("resize", update);
|
||
window.removeEventListener("scroll", update, true);
|
||
};
|
||
}, [range]);
|
||
|
||
return createPortal(
|
||
<button
|
||
ref={buttonRef}
|
||
type="button"
|
||
data-quote-selection
|
||
data-testid="quote-selection"
|
||
onMouseDown={(event) => {
|
||
// Keep the highlight alive until the click commits the quote.
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
}}
|
||
onClick={onQuote}
|
||
style={placement ? { top: placement.top, left: placement.left } : { visibility: "hidden" }}
|
||
className={cn(
|
||
"fixed z-50 flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-border bg-background px-3 py-1.5 text-ui-body-sm font-medium text-foreground shadow-md hover:bg-muted",
|
||
placement?.above === false ? "translate-y-0" : "-translate-y-full",
|
||
)}
|
||
>
|
||
<TextQuote strokeWidth={2} className="size-icon-sm" />
|
||
{t`Quote`}
|
||
</button>,
|
||
document.body,
|
||
);
|
||
});
|
||
|
||
const Composer = memo(function Composer({
|
||
running,
|
||
disabled,
|
||
pendingAttachments,
|
||
attachmentNotice,
|
||
sendError,
|
||
runError,
|
||
runErrorId,
|
||
onRunErrorPresented,
|
||
onDismissError,
|
||
sending,
|
||
fileInputRef,
|
||
onAttachmentPick,
|
||
onRemoveAttachment,
|
||
onSend,
|
||
onStop,
|
||
onVoice,
|
||
voiceReady = false,
|
||
voiceColor,
|
||
replyTarget,
|
||
replyQuote,
|
||
replyTargetName,
|
||
onClearReply,
|
||
mentionTargets,
|
||
agentSkills,
|
||
onSlashOpen,
|
||
onSlashAction,
|
||
}: {
|
||
running: boolean;
|
||
disabled?: boolean;
|
||
pendingAttachments: PendingAttachment[];
|
||
attachmentNotice: string | null;
|
||
sendError: string | null;
|
||
runError: string | null;
|
||
runErrorId: string | null;
|
||
onRunErrorPresented: (runId: string) => void;
|
||
onDismissError: () => void;
|
||
sending: boolean;
|
||
fileInputRef: RefObject<HTMLInputElement | null>;
|
||
onAttachmentPick: (files: FileList | null) => void | Promise<void>;
|
||
onRemoveAttachment: (attachment: PendingAttachment) => void;
|
||
onSend: (text: string, mentions?: ComposerMention[]) => Promise<void>;
|
||
onStop: () => Promise<void>;
|
||
onVoice?: () => void;
|
||
voiceReady?: boolean;
|
||
voiceColor?: string;
|
||
replyTarget?: ThreadMessage | null;
|
||
replyQuote?: string | null;
|
||
replyTargetName?: string;
|
||
onClearReply?: () => void;
|
||
mentionTargets?: ComposerMention[];
|
||
agentSkills?: AgentSkillCatalogEntry[];
|
||
onSlashOpen?: () => void;
|
||
onSlashAction?: (action: SlashActionId) => void;
|
||
}) {
|
||
const { t } = useLingui();
|
||
const [draft, setDraft] = useState("");
|
||
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||
const [mentionHighlightIndex, setMentionHighlightIndex] = useState(0);
|
||
const [slashQuery, setSlashQuery] = useState<string | null>(null);
|
||
const [selectedSkill, setSelectedSkill] = useState<AgentSkillCatalogEntry | null>(null);
|
||
const [selectedMentions, setSelectedMentions] = useState<ComposerMention[]>([]);
|
||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||
const runErrorRef = useRef<HTMLDivElement>(null);
|
||
const submitFingerprint = useRef<string | null>(null);
|
||
useEffect(() => {
|
||
if (!sendError && !runError) return;
|
||
const timer = setTimeout(() => {
|
||
onDismissError();
|
||
}, 8000);
|
||
return () => clearTimeout(timer);
|
||
}, [sendError, runError, onDismissError]);
|
||
|
||
const presentedRunErrorIdRef = useRef<string | null>(null);
|
||
const mentionListboxId = useId();
|
||
const dragDepth = useRef(0);
|
||
const [draggingFiles, setDraggingFiles] = useState(false);
|
||
const canSend =
|
||
draft.trim().length > 0 ||
|
||
selectedSkill !== null ||
|
||
selectedMentions.length > 0 ||
|
||
pendingAttachments.length > 0;
|
||
|
||
useEffect(() => {
|
||
if (!runError || !runErrorId) return;
|
||
const currentRunErrorId = runErrorId;
|
||
let frame = 0;
|
||
function recordIfPresented() {
|
||
cancelAnimationFrame(frame);
|
||
frame = requestAnimationFrame(() => {
|
||
const element = runErrorRef.current;
|
||
if (!element || document.visibilityState !== "visible") return;
|
||
const rect = element.getBoundingClientRect();
|
||
const topElement = document.elementFromPoint(
|
||
rect.left + rect.width / 2,
|
||
rect.top + rect.height / 2,
|
||
);
|
||
if (topElement && (topElement === element || element.contains(topElement))) {
|
||
if (presentedRunErrorIdRef.current === currentRunErrorId) return;
|
||
presentedRunErrorIdRef.current = currentRunErrorId;
|
||
onRunErrorPresented(currentRunErrorId);
|
||
}
|
||
});
|
||
}
|
||
recordIfPresented();
|
||
const observer = new MutationObserver(recordIfPresented);
|
||
observer.observe(document.body, { childList: true, subtree: true });
|
||
document.addEventListener("transitionend", recordIfPresented);
|
||
document.addEventListener("visibilitychange", recordIfPresented);
|
||
return () => {
|
||
cancelAnimationFrame(frame);
|
||
observer.disconnect();
|
||
document.removeEventListener("transitionend", recordIfPresented);
|
||
document.removeEventListener("visibilitychange", recordIfPresented);
|
||
};
|
||
}, [onRunErrorPresented, runError, runErrorId]);
|
||
|
||
useLayoutEffect(() => {
|
||
const el = textareaRef.current;
|
||
if (!el) return;
|
||
|
||
function syncHeight() {
|
||
const textarea = textareaRef.current;
|
||
if (!textarea) return;
|
||
textarea.style.height = "0px";
|
||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||
}
|
||
|
||
syncHeight();
|
||
let lastWidth = el.getBoundingClientRect().width;
|
||
const observer = new ResizeObserver(() => {
|
||
const textarea = textareaRef.current;
|
||
if (!textarea) return;
|
||
const width = textarea.getBoundingClientRect().width;
|
||
if (width === lastWidth) return;
|
||
lastWidth = width;
|
||
syncHeight();
|
||
});
|
||
observer.observe(el);
|
||
return () => observer.disconnect();
|
||
}, [draft]);
|
||
|
||
function updateDraft(value: string) {
|
||
submitFingerprint.current = null;
|
||
setDraft(value);
|
||
const mentionMatch = /(?:^|\s)@([\w-]*)$/.exec(value);
|
||
setMentionQuery(mentionMatch ? (mentionMatch[1] ?? "") : null);
|
||
// `/` only at the start of the draft so forced skills expand (`Use skill:` / `/Name` prefix).
|
||
const slashMatch = selectedSkill === null ? /^\/([^\n]*)$/.exec(value) : null;
|
||
const nextSlash = slashMatch ? (slashMatch[1] ?? "") : null;
|
||
if (nextSlash !== null && slashQuery === null) onSlashOpen?.();
|
||
setSlashQuery(nextSlash);
|
||
}
|
||
|
||
function focusComposer() {
|
||
textareaRef.current?.focus();
|
||
}
|
||
|
||
function insertMention(mention: ComposerMention) {
|
||
setDraft((current) => current.replace(/@([\w-]*)$/, ""));
|
||
setMentionQuery(null);
|
||
setMentionHighlightIndex(0);
|
||
setSelectedMentions((current) =>
|
||
current.some((selected) => mentionChipKey(selected) === mentionChipKey(mention))
|
||
? current
|
||
: [...current, mention],
|
||
);
|
||
focusComposer();
|
||
}
|
||
|
||
function insertSkill(skill: AgentSkillCatalogEntry) {
|
||
setSelectedSkill(skill);
|
||
setDraft("");
|
||
setSlashQuery(null);
|
||
}
|
||
|
||
function runSlashAction(action: SlashActionId) {
|
||
setDraft("");
|
||
setSlashQuery(null);
|
||
onSlashAction?.(action);
|
||
}
|
||
|
||
function removeLastChip() {
|
||
if (selectedMentions.length > 0) {
|
||
setSelectedMentions((current) => current.slice(0, -1));
|
||
return;
|
||
}
|
||
if (selectedSkill) setSelectedSkill(null);
|
||
}
|
||
|
||
const mentionOptions = useMemo(() => {
|
||
if (mentionQuery === null || !mentionTargets?.length) return [];
|
||
const query = mentionQuery.trim().toLowerCase();
|
||
return mentionTargets
|
||
.filter((target) => !query || target.name.toLowerCase().startsWith(query))
|
||
.slice(0, 10);
|
||
}, [mentionQuery, mentionTargets]);
|
||
|
||
useEffect(() => {
|
||
setMentionHighlightIndex(0);
|
||
}, [mentionQuery]);
|
||
|
||
const activeMentionIndex = clampMentionHighlightIndex(
|
||
mentionHighlightIndex,
|
||
mentionOptions.length,
|
||
);
|
||
const mentionPickerOpen = mentionOptions.length > 0;
|
||
const activeMentionOptionId = mentionPickerOpen
|
||
? `${mentionListboxId}-option-${activeMentionIndex}`
|
||
: undefined;
|
||
|
||
const slashSkillOptions = useMemo(() => {
|
||
if (slashQuery === null) return [];
|
||
const query = slashQuery.trim().toLowerCase();
|
||
const skills = agentSkills ?? [];
|
||
return skills
|
||
.filter((skill) => {
|
||
if (!query) return true;
|
||
return (
|
||
skill.name.toLowerCase().includes(query) ||
|
||
skill.description.toLowerCase().includes(query)
|
||
);
|
||
})
|
||
.slice(0, 8);
|
||
}, [agentSkills, slashQuery]);
|
||
|
||
const slashActionOptions = useMemo(() => {
|
||
if (slashQuery === null) return [];
|
||
const query = slashQuery.trim().toLowerCase();
|
||
return SLASH_ACTIONS.filter((action) => !query || action.label.toLowerCase().includes(query));
|
||
}, [slashQuery]);
|
||
|
||
const showSlashPicker =
|
||
slashQuery !== null &&
|
||
mentionQuery === null &&
|
||
(slashSkillOptions.length > 0 || slashActionOptions.length > 0);
|
||
|
||
function send() {
|
||
if (!canSend || disabled) return;
|
||
const text = serializeComposerPrompt(draft, selectedSkill, selectedMentions);
|
||
const fingerprint = [text, ...selectedMentions.map((mention) => mentionChipKey(mention))].join(
|
||
"\n",
|
||
);
|
||
if (submitFingerprint.current === fingerprint) return;
|
||
submitFingerprint.current = fingerprint;
|
||
setDraft("");
|
||
setMentionQuery(null);
|
||
setMentionHighlightIndex(0);
|
||
setSlashQuery(null);
|
||
setSelectedSkill(null);
|
||
const mentions = selectedMentions;
|
||
setSelectedMentions([]);
|
||
void onSend(text, mentions);
|
||
}
|
||
|
||
function handleDragEnter(event: DragEvent<HTMLFieldSetElement>) {
|
||
const dataTransfer = event.dataTransfer;
|
||
if (!isFileDrag(dataTransfer)) return;
|
||
event.preventDefault();
|
||
if (disabled) {
|
||
dragDepth.current = 0;
|
||
setDraggingFiles(false);
|
||
return;
|
||
}
|
||
dragDepth.current += 1;
|
||
setDraggingFiles(true);
|
||
}
|
||
|
||
function handleDragOver(event: DragEvent<HTMLFieldSetElement>) {
|
||
const dataTransfer = event.dataTransfer;
|
||
if (!isFileDrag(dataTransfer)) return;
|
||
event.preventDefault();
|
||
dataTransfer.dropEffect = disabled ? "none" : "copy";
|
||
if (disabled) {
|
||
dragDepth.current = 0;
|
||
setDraggingFiles(false);
|
||
return;
|
||
}
|
||
setDraggingFiles(true);
|
||
}
|
||
|
||
function handleDragLeave(event: DragEvent<HTMLFieldSetElement>) {
|
||
if (!isFileDrag(event.dataTransfer)) return;
|
||
if (disabled) {
|
||
dragDepth.current = 0;
|
||
setDraggingFiles(false);
|
||
return;
|
||
}
|
||
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||
if (dragDepth.current === 0) setDraggingFiles(false);
|
||
}
|
||
|
||
function handleDrop(event: DragEvent<HTMLFieldSetElement>) {
|
||
const dataTransfer = event.dataTransfer;
|
||
if (!isFileDrag(dataTransfer)) return;
|
||
event.preventDefault();
|
||
dragDepth.current = 0;
|
||
setDraggingFiles(false);
|
||
if (!disabled) void onAttachmentPick(dataTransfer.files);
|
||
}
|
||
|
||
function handlePaste(event: ClipboardEvent<HTMLTextAreaElement>) {
|
||
const clipboardData = event.clipboardData;
|
||
// Only intercept real FileList pastes; leave text-only / empty-files native.
|
||
if (disabled || !clipboardData || !isFilePaste(clipboardData)) return;
|
||
event.preventDefault();
|
||
void onAttachmentPick(clipboardData.files);
|
||
const text = clipboardData.getData("text/plain");
|
||
if (!text) return;
|
||
const textarea = event.currentTarget;
|
||
const start = textarea.selectionStart;
|
||
const end = textarea.selectionEnd;
|
||
updateDraft(`${draft.slice(0, start)}${text}${draft.slice(end)}`);
|
||
const caret = start + text.length;
|
||
window.requestAnimationFrame(() => {
|
||
textareaRef.current?.setSelectionRange(caret, caret);
|
||
});
|
||
}
|
||
|
||
const showComposerPlaceholder =
|
||
draft.length === 0 && selectedSkill === null && selectedMentions.length === 0;
|
||
const replyName = replyTarget ? (replyTargetName ?? previewMessageText(replyTarget)) : "";
|
||
|
||
return (
|
||
<fieldset
|
||
aria-label={t`Message composer`}
|
||
data-dragging={draggingFiles ? "files" : undefined}
|
||
onDragEnter={handleDragEnter}
|
||
onDragOver={handleDragOver}
|
||
onDragLeave={handleDragLeave}
|
||
onDrop={handleDrop}
|
||
className={`relative z-30 m-0 min-w-0 border-0 px-3 pb-4 pt-3 md:px-6 md:pb-6 ${
|
||
draggingFiles ? "rounded-[14px] ring-2 ring-inset ring-ring" : ""
|
||
}`}
|
||
>
|
||
<div className="mx-auto w-full max-w-4xl">
|
||
{sendError || runError ? (
|
||
<div
|
||
ref={runErrorRef}
|
||
role="alert"
|
||
data-testid="composer-error"
|
||
className="mb-3 flex items-center gap-2 rounded-[14px] border border-destructive/40 bg-destructive/10 px-4 py-2 text-ui-body-sm text-destructive"
|
||
>
|
||
<span className="min-w-0 flex-1">{sendError ?? runError}</span>
|
||
<button
|
||
type="button"
|
||
aria-label={t`Dismiss error`}
|
||
data-testid="composer-error-dismiss"
|
||
onClick={() => {
|
||
onDismissError();
|
||
window.requestAnimationFrame(() => textareaRef.current?.focus());
|
||
}}
|
||
className="shrink-0 text-destructive hover:text-foreground"
|
||
>
|
||
<X strokeWidth={2} className="size-icon-sm" />
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
{replyTarget ? (
|
||
<div
|
||
data-testid="reply-chip"
|
||
className="mb-2 flex items-center gap-2 rounded-full border border-border bg-muted px-3 py-1.5 text-ui-body-sm text-foreground/75"
|
||
>
|
||
<span className="min-w-0 flex-1 truncate text-muted-foreground">
|
||
{replyQuote
|
||
? t`Replying to ${replyName}: “${replyQuote}”`
|
||
: t`Replying to ${replyName}`}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
aria-label={t`Cancel reply`}
|
||
onClick={onClearReply}
|
||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||
>
|
||
<X strokeWidth={2} className="size-icon-sm" />
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
{attachmentNotice ? (
|
||
<div className="mb-3 rounded-[14px] border border-warning/40 bg-warning/10 px-4 py-2 text-ui-body-sm text-warning">
|
||
{attachmentNotice}
|
||
</div>
|
||
) : null}
|
||
{pendingAttachments.length ? (
|
||
<div className="mb-3 flex flex-wrap gap-2">
|
||
{pendingAttachments.map((attachment) => (
|
||
<div
|
||
key={attachment.id}
|
||
className="flex items-center gap-2 rounded-full border border-border bg-muted px-3 py-1.5 text-ui-body-sm text-foreground/75"
|
||
>
|
||
{attachment.previewUrl ? (
|
||
<img
|
||
src={attachment.previewUrl}
|
||
alt={attachment.file.name}
|
||
className="size-control-md rounded object-cover"
|
||
/>
|
||
) : (
|
||
<Paperclip strokeWidth={1.8} className="size-icon-md" />
|
||
)}
|
||
<span className="max-w-[180px] truncate" dir="auto">
|
||
{attachment.file.name}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
aria-label={t`Remove ${attachment.file.name}`}
|
||
onClick={() => onRemoveAttachment(attachment)}
|
||
className="text-muted-foreground hover:text-foreground"
|
||
>
|
||
<X strokeWidth={2} className="size-icon-sm" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
{mentionPickerOpen ? (
|
||
<div
|
||
id={mentionListboxId}
|
||
role="listbox"
|
||
aria-label={t`Mentions`}
|
||
data-testid="mention-picker"
|
||
className="mb-2 max-h-64 overflow-y-auto rounded-[14px] border border-border bg-muted"
|
||
>
|
||
{mentionOptions.map((mention, index) => {
|
||
const optionId = `${mentionListboxId}-option-${index}`;
|
||
const highlighted = index === activeMentionIndex;
|
||
return (
|
||
<button
|
||
id={optionId}
|
||
key={mentionChipKey(mention)}
|
||
type="button"
|
||
role="option"
|
||
aria-selected={highlighted}
|
||
aria-label={`@${mention.name}`}
|
||
data-mention-name={mention.name}
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onClick={() => insertMention(mention)}
|
||
onMouseEnter={() => setMentionHighlightIndex(index)}
|
||
className={`flex w-full items-start gap-3 px-4 py-2.5 text-start hover:bg-accent ${
|
||
highlighted ? "bg-accent" : ""
|
||
}`}
|
||
>
|
||
<span aria-hidden="true">
|
||
<MentionOptionIcon mention={mention} />
|
||
</span>
|
||
<span className="min-w-0">
|
||
<span dir="auto" className="block text-ui-body text-foreground">
|
||
@{mention.name}
|
||
</span>
|
||
{mention.subtitle ? (
|
||
<span
|
||
dir="auto"
|
||
aria-hidden="true"
|
||
className="block truncate text-ui-body-sm text-muted-foreground"
|
||
>
|
||
{mention.subtitle}
|
||
</span>
|
||
) : null}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : null}
|
||
{showSlashPicker ? (
|
||
<div
|
||
data-testid="slash-picker"
|
||
className="mb-2 overflow-hidden rounded-[14px] border border-border bg-muted"
|
||
>
|
||
{slashSkillOptions.map((skill) => (
|
||
<button
|
||
key={skill.id}
|
||
type="button"
|
||
aria-label={t`Skill ${skill.name}`}
|
||
onClick={() => insertSkill(skill)}
|
||
className="flex w-full items-start gap-3 px-4 py-2.5 text-start hover:bg-accent"
|
||
>
|
||
<Box
|
||
strokeWidth={1.7}
|
||
className="mt-0.5 shrink-0 text-muted-foreground size-icon-md"
|
||
/>
|
||
<span className="min-w-0">
|
||
<span dir="auto" className="block text-ui-body text-foreground">
|
||
{skill.name}
|
||
</span>
|
||
<span dir="auto" className="block truncate text-ui-body-sm text-muted-foreground">
|
||
{truncateSlashDescription(skill.description)}
|
||
</span>
|
||
</span>
|
||
</button>
|
||
))}
|
||
{slashActionOptions.map((action) => {
|
||
const label = slashActionLabel(action.id);
|
||
return (
|
||
<button
|
||
key={action.id}
|
||
type="button"
|
||
aria-label={label}
|
||
onClick={() => runSlashAction(action.id)}
|
||
className="flex w-full items-center gap-3 px-4 py-2.5 text-start hover:bg-accent"
|
||
>
|
||
<Settings
|
||
strokeWidth={1.7}
|
||
className="shrink-0 text-muted-foreground size-icon-md"
|
||
/>
|
||
<span className="text-ui-body text-foreground">{label}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : null}
|
||
<div
|
||
data-testid="composer-bar"
|
||
className="apple-island relative flex items-center gap-3.5 rounded-[30px] py-3 pe-3.5 ps-4 transition-all duration-200"
|
||
>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
multiple
|
||
accept={ATTACHMENT_ACCEPT}
|
||
className="hidden"
|
||
onChange={(event) => void onAttachmentPick(event.target.files)}
|
||
/>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
aria-label={t`Attach file`}
|
||
disabled={disabled}
|
||
onClick={() => fileInputRef.current?.click()}
|
||
className="tactile-press size-10 shrink-0 rounded-full border border-border bg-foreground/5 text-foreground/80 transition-all hover:bg-foreground/10 hover:text-foreground flex items-center justify-center cursor-pointer"
|
||
>
|
||
<Plus strokeWidth={2.2} className="size-icon-lg" />
|
||
</Button>
|
||
<div className="flex min-w-0 flex-1 flex-wrap items-end gap-1.5">
|
||
{selectedSkill ? (
|
||
<span
|
||
data-testid="skill-chip"
|
||
className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-border bg-foreground/[0.08] px-3 py-1 text-ui-body font-medium text-foreground backdrop-blur-md"
|
||
>
|
||
<Box strokeWidth={1.8} className="shrink-0 text-muted-foreground size-icon-md" />
|
||
<span dir="auto" className="truncate">
|
||
{selectedSkill.name}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
aria-label={t`Remove skill ${selectedSkill.name}`}
|
||
onClick={() => setSelectedSkill(null)}
|
||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||
>
|
||
<X strokeWidth={2} className="size-icon-sm" />
|
||
</button>
|
||
</span>
|
||
) : null}
|
||
{selectedMentions.map((mention) => (
|
||
<span
|
||
key={mentionChipKey(mention)}
|
||
data-testid="mention-chip"
|
||
data-mention-kind={mention.kind}
|
||
className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-border bg-foreground/[0.08] px-3 py-1 text-ui-body font-medium text-foreground backdrop-blur-md"
|
||
>
|
||
<MentionChipIcon mention={mention} />
|
||
<span dir="auto" className="truncate">
|
||
{mention.name}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
aria-label={t`Remove mention ${mention.name}`}
|
||
onClick={() =>
|
||
setSelectedMentions((current) =>
|
||
current.filter(
|
||
(selected) => mentionChipKey(selected) !== mentionChipKey(mention),
|
||
),
|
||
)
|
||
}
|
||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||
>
|
||
<X strokeWidth={2} className="size-icon-sm" />
|
||
</button>
|
||
</span>
|
||
))}
|
||
<textarea
|
||
ref={textareaRef}
|
||
value={draft}
|
||
onChange={(event) => updateDraft(event.target.value)}
|
||
onPaste={handlePaste}
|
||
onKeyDown={(event) => {
|
||
if (
|
||
event.key === "Backspace" &&
|
||
draft.length === 0 &&
|
||
(selectedSkill !== null || selectedMentions.length > 0)
|
||
) {
|
||
event.preventDefault();
|
||
removeLastChip();
|
||
return;
|
||
}
|
||
const action = resolveMentionPickerKey({
|
||
key: event.key,
|
||
shiftKey: event.shiftKey,
|
||
isComposing: event.nativeEvent.isComposing || event.keyCode === 229,
|
||
optionCount: mentionOptions.length,
|
||
highlightedIndex: activeMentionIndex,
|
||
});
|
||
if (action.type === "complete") {
|
||
const mention = mentionOptions[action.index];
|
||
if (!mention) return;
|
||
event.preventDefault();
|
||
insertMention(mention);
|
||
return;
|
||
}
|
||
if (action.type === "move") {
|
||
event.preventDefault();
|
||
setMentionHighlightIndex(action.index);
|
||
return;
|
||
}
|
||
if (action.type === "dismiss") {
|
||
event.preventDefault();
|
||
setMentionQuery(null);
|
||
setMentionHighlightIndex(0);
|
||
return;
|
||
}
|
||
if (action.type === "send") {
|
||
event.preventDefault();
|
||
send();
|
||
}
|
||
}}
|
||
disabled={disabled}
|
||
placeholder={showComposerPlaceholder ? t`Message…` : undefined}
|
||
aria-label={t`Message`}
|
||
role="combobox"
|
||
aria-autocomplete="list"
|
||
aria-haspopup="listbox"
|
||
aria-expanded={mentionPickerOpen ? "true" : "false"}
|
||
aria-controls={mentionPickerOpen ? mentionListboxId : undefined}
|
||
aria-activedescendant={activeMentionOptionId}
|
||
name="chat-message"
|
||
autoComplete="off"
|
||
dir="auto"
|
||
rows={1}
|
||
className="max-h-36 min-h-[28px] min-w-[8rem] flex-1 resize-none overflow-y-auto bg-transparent py-1 text-[16.5px] leading-[1.55] text-foreground outline-none placeholder:text-muted-foreground/75 disabled:opacity-40 font-normal"
|
||
/>
|
||
</div>
|
||
{onVoice ? (
|
||
<Button
|
||
variant="outline"
|
||
aria-label={t`Voice`}
|
||
title={voiceReady ? t`Start voice conversation` : t`Set up voice`}
|
||
disabled={disabled}
|
||
onClick={onVoice}
|
||
data-testid="composer-voice-primary"
|
||
className={`tactile-press h-control-lg shrink-0 gap-2 rounded-full px-4 sm:px-5 flex items-center justify-center cursor-pointer transition-all ${
|
||
voiceReady
|
||
? "relative overflow-hidden border-border bg-foreground/5 text-foreground hover:bg-foreground/10"
|
||
: "border-border bg-foreground/5 text-foreground/80 hover:bg-foreground/10"
|
||
}`}
|
||
>
|
||
{voiceReady ? (
|
||
<VoiceAuroraRim botColor={voiceColor} className="inset-x-[14%] h-0.5 blur-[3px]" />
|
||
) : null}
|
||
<Mic strokeWidth={1.9} className="size-icon-lg shrink-0" />
|
||
<span className="hidden text-ui-body font-semibold sm:inline">
|
||
<Trans>Voice</Trans>
|
||
</span>
|
||
</Button>
|
||
) : null}
|
||
{running ? (
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
<Button
|
||
size="icon"
|
||
aria-label={t`Send`}
|
||
disabled={!canSend || disabled}
|
||
onClick={send}
|
||
className="tactile-press size-10 rounded-full transition-transform active:scale-90 flex items-center justify-center cursor-pointer"
|
||
>
|
||
<ArrowUp strokeWidth={2.6} className="size-icon-lg" />
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
aria-label={t`Stop`}
|
||
disabled={sending}
|
||
onClick={() => void onStop()}
|
||
className="tactile-press size-10 rounded-full border border-border bg-foreground/[0.08] text-foreground/90 shadow-sm transition-colors hover:bg-foreground/15 hover:text-foreground flex items-center justify-center cursor-pointer"
|
||
>
|
||
<Square strokeWidth={0} fill="currentColor" className="size-icon-sm" />
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<Button
|
||
size="icon"
|
||
aria-label={t`Send`}
|
||
disabled={!canSend || disabled}
|
||
onClick={send}
|
||
className="tactile-press size-10 shrink-0 rounded-full transition-all active:scale-90 disabled:bg-foreground/[0.08] disabled:text-foreground/20 flex items-center justify-center cursor-pointer"
|
||
>
|
||
<ArrowUp strokeWidth={2.6} className="size-icon-lg" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</fieldset>
|
||
);
|
||
});
|
||
|
||
function slashActionLabel(id: SlashActionId) {
|
||
switch (id) {
|
||
case "chat-settings":
|
||
return t`Chat Settings`;
|
||
case "settings-general":
|
||
return t`Settings: General`;
|
||
case "settings-usage":
|
||
return t`Settings: Usage`;
|
||
}
|
||
}
|
||
|
||
function MentionOptionIcon({ mention }: { mention: ComposerMention }) {
|
||
if (mention.kind === "routine") {
|
||
return (
|
||
<Clock strokeWidth={1.7} className="mt-0.5 shrink-0 text-muted-foreground size-icon-md" />
|
||
);
|
||
}
|
||
if (mention.kind === "connector") {
|
||
return (
|
||
<Puzzle strokeWidth={1.7} className="mt-0.5 shrink-0 text-muted-foreground size-icon-md" />
|
||
);
|
||
}
|
||
if (mention.kind === "group") {
|
||
return (
|
||
<span className="mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full bg-accent text-ui-caption text-foreground/75">
|
||
G
|
||
</span>
|
||
);
|
||
}
|
||
if (mention.kind === "everyone") {
|
||
return (
|
||
<span className="mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full bg-accent text-ui-caption text-foreground/75">
|
||
@
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<BotAvatar
|
||
color={mention.color ?? FALLBACK_BOT_COLOR}
|
||
identity={mention.id}
|
||
className="size-icon-md"
|
||
/>
|
||
);
|
||
}
|
||
|
||
function MentionChipIcon({ mention }: { mention: ComposerMention }) {
|
||
if (mention.kind === "routine") {
|
||
return <Clock strokeWidth={1.7} className="shrink-0 text-muted-foreground/70 size-icon-sm" />;
|
||
}
|
||
if (mention.kind === "connector") {
|
||
return <Puzzle strokeWidth={1.7} className="shrink-0 text-muted-foreground/70 size-icon-sm" />;
|
||
}
|
||
if (mention.kind === "group" || mention.kind === "everyone") {
|
||
return (
|
||
<span className="grid h-4 w-4 shrink-0 place-items-center rounded-full bg-accent text-ui-caption text-foreground/75">
|
||
{mention.kind === "group" ? "G" : "@"}
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<BotAvatar
|
||
color={mention.color ?? FALLBACK_BOT_COLOR}
|
||
identity={mention.id}
|
||
className="size-icon-md"
|
||
/>
|
||
);
|
||
}
|
||
|
||
function previewMessageText(message: ThreadMessage): string {
|
||
const text = message.blocks
|
||
.map((block) => (block.kind === "text" || block.kind === "channel_message" ? block.text : ""))
|
||
.filter(Boolean)
|
||
.join(" ")
|
||
.trim();
|
||
if (text) return text;
|
||
if (message.blocks.some((block) => block.kind === "image" || block.kind === "file")) {
|
||
return t`Attachment`;
|
||
}
|
||
return t`Message`;
|
||
}
|
||
|
||
function formatRosterTime(isoDate?: string | null): string {
|
||
if (!isoDate) return "";
|
||
try {
|
||
const d = new Date(isoDate);
|
||
if (Number.isNaN(d.getTime())) return "";
|
||
const locale = i18n.locale || "en";
|
||
const now = new Date();
|
||
const isToday =
|
||
d.getDate() === now.getDate() &&
|
||
d.getMonth() === now.getMonth() &&
|
||
d.getFullYear() === now.getFullYear();
|
||
if (isToday) {
|
||
return d.toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit" });
|
||
}
|
||
const yesterday = new Date(now);
|
||
yesterday.setDate(now.getDate() - 1);
|
||
const isYesterday =
|
||
d.getDate() === yesterday.getDate() &&
|
||
d.getMonth() === yesterday.getMonth() &&
|
||
d.getFullYear() === yesterday.getFullYear();
|
||
if (isYesterday) return t`Yesterday`;
|
||
const diffDays = Math.floor((now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24));
|
||
if (diffDays < 7) {
|
||
return d.toLocaleDateString(locale, { weekday: "short" });
|
||
}
|
||
return d.toLocaleDateString(locale, { month: "short", day: "numeric" });
|
||
} catch {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
function cleanRosterPreview(raw?: string | null): string {
|
||
if (!raw) return "";
|
||
let text = raw;
|
||
// Strip markdown headings
|
||
text = text.replace(/^#{1,6}\s+/gm, "");
|
||
// Strip routing tokens [to ...] and system protocols [RECOVERY PROTOCOL], [PO ...]
|
||
text = text.replace(/\[(?:to|RECOVERY PROTOCOL|PO)[^\]]*\]/gi, "");
|
||
// Strip bold/italic/code markers
|
||
text = text.replace(/[*_`#]/g, "");
|
||
// Strip escape backslashes
|
||
text = text.replace(/\\/g, "");
|
||
// Collapse whitespace
|
||
text = text.replace(/\s+/g, " ").trim();
|
||
return text;
|
||
}
|
||
|
||
function MessageHoverActions({
|
||
message,
|
||
side,
|
||
onReply,
|
||
onReact,
|
||
}: {
|
||
message: ThreadMessage;
|
||
side: "start" | "end";
|
||
onReply: (message: ThreadMessage) => void;
|
||
onReact: (message: ThreadMessage, reaction: MessageReaction) => Promise<void>;
|
||
}) {
|
||
const { t } = useLingui();
|
||
const [moreOpen, setMoreOpen] = useState(false);
|
||
const [reactionsOpen, setReactionsOpen] = useState(false);
|
||
|
||
// Streaming progress bubbles keep hover free for selection / stop clicks.
|
||
if (message.id.startsWith("progress:")) return null;
|
||
|
||
function copyMessage() {
|
||
const text = copyableMessageText(message);
|
||
if (!text || !navigator.clipboard) return;
|
||
void navigator.clipboard.writeText(text).catch(() => undefined);
|
||
}
|
||
|
||
const iconButtonClass =
|
||
"grid size-control-sm place-items-center text-muted-foreground transition-colors hover:text-foreground";
|
||
|
||
return (
|
||
<MessageHoverMetadata pinned={moreOpen || reactionsOpen} side={side}>
|
||
<div data-testid="message-hover-actions" className="flex items-center gap-0.5">
|
||
{canReactToThreadMessage(message) ? (
|
||
<Popover open={reactionsOpen} onOpenChange={setReactionsOpen}>
|
||
<PopoverTrigger
|
||
aria-label={t`React`}
|
||
className={cn(
|
||
iconButtonClass,
|
||
"h-11 w-11 [@media(hover:hover)_and_(pointer:fine)]:h-7 [@media(hover:hover)_and_(pointer:fine)]:w-7",
|
||
)}
|
||
>
|
||
<Smile strokeWidth={1.7} className="size-icon-md" />
|
||
</PopoverTrigger>
|
||
<PopoverContent
|
||
align={side === "end" ? "start" : "end"}
|
||
className="w-auto flex-row gap-0 rounded-2xl p-1.5"
|
||
aria-label={t`Reactions`}
|
||
>
|
||
{MESSAGE_REACTIONS.map((emoji) => (
|
||
<button
|
||
key={emoji}
|
||
type="button"
|
||
aria-label={emoji}
|
||
className="grid h-11 w-11 place-items-center rounded-xl text-2xl hover:bg-accent focus-visible:outline-2 focus-visible:outline-ring"
|
||
onClick={() => {
|
||
setReactionsOpen(false);
|
||
void onReact(message, emoji);
|
||
}}
|
||
>
|
||
{emoji}
|
||
</button>
|
||
))}
|
||
</PopoverContent>
|
||
</Popover>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
aria-label={t`Reply`}
|
||
onClick={() => onReply(message)}
|
||
className={`${iconButtonClass} hidden [@media(hover:hover)_and_(pointer:fine)]:grid`}
|
||
>
|
||
<Reply strokeWidth={1.7} className="size-icon-md" />
|
||
</button>
|
||
<DropdownMenu open={moreOpen} onOpenChange={setMoreOpen}>
|
||
<DropdownMenuTrigger
|
||
aria-label={t`More`}
|
||
className={cn(
|
||
iconButtonClass,
|
||
"h-11 w-11 [@media(hover:hover)_and_(pointer:fine)]:h-7 [@media(hover:hover)_and_(pointer:fine)]:w-7",
|
||
)}
|
||
>
|
||
<MoreHorizontal strokeWidth={1.7} className="size-icon-md" />
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align={side === "end" ? "start" : "end"}>
|
||
<DropdownMenuItem
|
||
className="[@media(hover:hover)_and_(pointer:fine)]:hidden"
|
||
onClick={() => onReply(message)}
|
||
>
|
||
<Reply className="size-icon-md" />
|
||
<Trans>Reply</Trans>
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem onClick={copyMessage}>
|
||
<Copy strokeWidth={1.7} className="size-icon-md" />
|
||
<Trans>Copy</Trans>
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
</MessageHoverMetadata>
|
||
);
|
||
}
|
||
|
||
function firstThreadRoute(
|
||
bots: readonly Pick<Bot, "id">[],
|
||
groups: readonly Pick<Group, "id" | "kind">[],
|
||
/** In a project the user lands in its main conversation. */
|
||
inProject = false,
|
||
): string {
|
||
const main = inProject ? groups.find((group) => group.kind === "main") : undefined;
|
||
if (main) return `/app/g/${main.id}`;
|
||
if (bots[0]) return `/app/${bots[0].id}`;
|
||
if (groups[0]) return `/app/g/${groups[0].id}`;
|
||
return "/app";
|
||
}
|
||
|
||
function applyThreadEvent(
|
||
event: ProductEvent,
|
||
commitSnapshot: (next: ThreadSnapshot | null) => void,
|
||
commitComputer: (next: ComputerStatus | null) => void,
|
||
snapshotRef: MutableRefObject<ThreadSnapshot | null>,
|
||
computerRef: MutableRefObject<ComputerStatus | null>,
|
||
) {
|
||
if (isThreadSnapshotEvent(event)) {
|
||
const next = reduceThreadSnapshot(snapshotRef.current, event);
|
||
commitSnapshot(next);
|
||
}
|
||
if (isComputerStatusEvent(event)) {
|
||
const next = reduceComputerStatus(computerRef.current, event);
|
||
commitComputer(next);
|
||
}
|
||
}
|
||
|
||
function ComputerReleaseActions({
|
||
takeoverRequested,
|
||
onRelease,
|
||
}: {
|
||
takeoverRequested: boolean;
|
||
onRelease: (reason?: ComputerReleaseReason) => Promise<void>;
|
||
}) {
|
||
if (!takeoverRequested) {
|
||
return (
|
||
<Button type="button" variant="outline" size="sm" onClick={() => void onRelease()}>
|
||
<Trans>Release</Trans>
|
||
</Button>
|
||
);
|
||
}
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
<Button type="button" variant="outline" size="sm" onClick={() => void onRelease("skipped")}>
|
||
<Trans>Skip</Trans>
|
||
</Button>
|
||
<Button type="button" size="sm" onClick={() => void onRelease("done")}>
|
||
<Trans>I’m done</Trans>
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const MessageView = memo(function MessageView({
|
||
artifactTarget,
|
||
onOpenArtifact,
|
||
canAnswer,
|
||
message,
|
||
onAnswer,
|
||
onOpenBot,
|
||
onOpenGroup,
|
||
onOpenPeerMessages,
|
||
speakerName,
|
||
memberName,
|
||
peerBot,
|
||
replyPreview,
|
||
replyToMessageId,
|
||
onJumpToMessage,
|
||
onRefresh,
|
||
onBotChanged,
|
||
onAddRoutine,
|
||
voiceReady,
|
||
speaking,
|
||
onSpeak,
|
||
encounterMode = false,
|
||
}: {
|
||
artifactTarget: ArtifactTarget;
|
||
onOpenArtifact?: (artifact: WorkspaceArtifact) => void;
|
||
canAnswer: boolean;
|
||
message: ThreadMessage;
|
||
onAnswer: (message: ThreadMessage, text: string) => Promise<void>;
|
||
onOpenBot: (botId: string) => void;
|
||
onOpenGroup: (groupId: string) => void;
|
||
onOpenPeerMessages: (peer: { peerBotId: string; peerBotName: string }) => void;
|
||
speakerName?: string;
|
||
memberName?: (botId: string | undefined) => string | undefined;
|
||
peerBot: (botId: string) => { color: string; status?: string } | undefined;
|
||
replyPreview?: ThreadMessage;
|
||
replyToMessageId?: string;
|
||
onJumpToMessage?: (messageId: string) => void;
|
||
onRefresh: () => Promise<void>;
|
||
onBotChanged: () => Promise<void>;
|
||
onAddRoutine: (name: string, prompt: string) => void;
|
||
voiceReady: boolean;
|
||
speaking: boolean;
|
||
onSpeak: () => void;
|
||
encounterMode?: boolean;
|
||
}) {
|
||
const { t } = useLingui();
|
||
const isNarration =
|
||
message.role === "bot" &&
|
||
message.blocks.length > 0 &&
|
||
message.blocks.every(
|
||
(block) => block.kind === "text" || block.kind === "progress" || block.kind === "steps",
|
||
);
|
||
const isLive = message.id.startsWith("progress:");
|
||
const visibleNarrationBlocks = message.blocks.filter((block) => !isToolActivityBlock(block));
|
||
const parentJumpId = replyPreview?.id ?? replyToMessageId;
|
||
const speakerBot = message.botId ? peerBot?.(message.botId) : undefined;
|
||
const speakerColorDef = useMemo(
|
||
() => resolvePersonaColorDef(message.botId ?? "bot", speakerBot?.color),
|
||
[message.botId, speakerBot?.color],
|
||
);
|
||
const effectiveSpeakerName = speakerName || (message.role === "bot" ? "evOo" : null);
|
||
const messageContext = (
|
||
<>
|
||
{effectiveSpeakerName && message.role === "bot" && !encounterMode ? (
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<div className="flex items-center gap-2.5">
|
||
<div className="relative flex items-center justify-center size-7 rounded-xl bg-foreground/[0.08] border border-border shadow-xs">
|
||
<EvooMascot size={22} interactive={false} pose={isLive ? "working" : "idle"} />
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span
|
||
className="text-[14.5px] font-semibold text-foreground tracking-tight"
|
||
dir="auto"
|
||
>
|
||
{effectiveSpeakerName}
|
||
</span>
|
||
<span className="rounded-full border border-border bg-muted px-2 py-0.5 text-ui-label font-medium text-muted-foreground">
|
||
Oo
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<span className="text-ui-label font-mono text-muted-foreground tabular-nums">
|
||
{new Date(message.createdAt).toLocaleTimeString([], {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
})}
|
||
</span>
|
||
</div>
|
||
) : effectiveSpeakerName && message.role === "bot" && encounterMode ? (
|
||
<div className="mb-1 flex items-center gap-2 text-ui-label font-medium uppercase tracking-wide text-muted-foreground">
|
||
<span dir="auto">{effectiveSpeakerName}</span>
|
||
<span aria-hidden>·</span>
|
||
<time dateTime={message.createdAt} className="font-mono tabular-nums normal-case">
|
||
{new Date(message.createdAt).toLocaleTimeString([], {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
})}
|
||
</time>
|
||
</div>
|
||
) : speakerName ? (
|
||
<div
|
||
className="mb-1.5 flex items-center gap-2 text-ui-body-sm font-semibold tracking-tight"
|
||
dir="auto"
|
||
style={{ color: speakerColorDef.light }}
|
||
>
|
||
<BotAvatar
|
||
color={speakerBot?.color ?? FALLBACK_BOT_COLOR}
|
||
identity={message.botId}
|
||
size={22}
|
||
/>
|
||
{speakerName}
|
||
</div>
|
||
) : null}
|
||
{parentJumpId ? (
|
||
<button
|
||
type="button"
|
||
data-testid="reply-parent-preview"
|
||
aria-label={t`Jump to replied message`}
|
||
onClick={() => onJumpToMessage?.(parentJumpId)}
|
||
className="mb-2 block max-w-[74%] truncate rounded-[14px] border border-border bg-background px-3 py-2 text-start text-ui-body-sm text-muted-foreground hover:border-border hover:text-foreground/75"
|
||
dir="auto"
|
||
>
|
||
{message.replyQuote
|
||
? `“${message.replyQuote}”`
|
||
: replyPreview
|
||
? previewMessageText(replyPreview)
|
||
: t`Earlier message`}
|
||
</button>
|
||
) : null}
|
||
</>
|
||
);
|
||
if (isNarration) {
|
||
if (visibleNarrationBlocks.length === 0) return null;
|
||
return (
|
||
<>
|
||
{messageContext}
|
||
<div className="flex w-fit max-w-full justify-start">
|
||
<div
|
||
data-testid="message-bot-bubble"
|
||
className={
|
||
encounterMode
|
||
? "max-w-full space-y-2 border-s-2 border-emerald-500/35 ps-3 text-ui-body-sm leading-relaxed text-foreground/90"
|
||
: "chat-bubble-bot max-w-full space-y-3 rounded-[24px] px-5 py-3.5 text-ui-body leading-[1.62] text-foreground/95"
|
||
}
|
||
dir="auto"
|
||
>
|
||
{visibleNarrationBlocks.map((block, i) => {
|
||
if (block.kind === "text" || block.kind === "progress") {
|
||
return (
|
||
<div key={i}>
|
||
<ChatMarkdown streaming={block.kind === "progress"}>{block.text}</ChatMarkdown>
|
||
</div>
|
||
);
|
||
}
|
||
return null;
|
||
})}
|
||
{!isLive && voiceReady && message.blocks.some((block) => block.kind === "text") ? (
|
||
<button
|
||
type="button"
|
||
aria-label={speaking ? t`Stop speaking` : t`Speak this reply`}
|
||
onClick={onSpeak}
|
||
className="mt-1 text-ui-body-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||
>
|
||
{speaking ? <Trans>Stop</Trans> : <Trans>Speak</Trans>}
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
return (
|
||
<>
|
||
{messageContext}
|
||
{message.blocks.map((block, i) => {
|
||
if (isToolActivityBlock(block)) return null;
|
||
if (block.kind === "handoff") {
|
||
const from = memberName?.(block.fromBotId) ?? t`bot`;
|
||
const to = memberName?.(block.toBotId) ?? t`bot`;
|
||
return (
|
||
<div
|
||
key={i}
|
||
className="flex items-center justify-center gap-2 py-1 text-ui-body text-muted-foreground"
|
||
>
|
||
<span>
|
||
↪ {to} ← {from}
|
||
</span>
|
||
<span>{block.text}</span>
|
||
</div>
|
||
);
|
||
}
|
||
if (block.kind === "bot_message_sent") {
|
||
const peer = block.toBotName;
|
||
const peerBotId = block.toBotId;
|
||
return (
|
||
<SentMessageCard
|
||
key={i}
|
||
botId={peerBotId}
|
||
botName={peer}
|
||
color={peerBot(peerBotId)?.color ?? FALLBACK_BOT_COLOR}
|
||
text={block.text}
|
||
onClick={() => onOpenPeerMessages({ peerBotId, peerBotName: peer })}
|
||
/>
|
||
);
|
||
}
|
||
if (block.kind === "task") {
|
||
return (
|
||
<DelegatedTaskCard
|
||
key={i}
|
||
botId={block.assigneeBotId}
|
||
botName={block.assigneeBotName}
|
||
color={peerBot(block.assigneeBotId)?.color ?? FALLBACK_BOT_COLOR}
|
||
title={block.title}
|
||
text={block.text}
|
||
status={block.status}
|
||
/>
|
||
);
|
||
}
|
||
if (block.kind === "bot_message_received") {
|
||
const peer = block.fromBotName;
|
||
const peerBotId = block.fromBotId;
|
||
const label = t`Message from ${peer}`;
|
||
return (
|
||
<CollaborationMarker
|
||
key={i}
|
||
ariaLabel={label}
|
||
color={peerBot(peerBotId)?.color ?? FALLBACK_BOT_COLOR}
|
||
identity={peerBotId}
|
||
label={label}
|
||
onClick={() => onOpenPeerMessages({ peerBotId, peerBotName: peer })}
|
||
/>
|
||
);
|
||
}
|
||
if (block.kind === "channel_message") {
|
||
return (
|
||
<div
|
||
key={i}
|
||
className="flex items-center justify-center gap-2 py-1 text-ui-body text-muted-foreground"
|
||
>
|
||
<span>
|
||
{messageProviderLabel(block.provider, block.transport)} · {block.fromLabel}:{" "}
|
||
{block.text}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
if (block.kind === "meta") {
|
||
return (
|
||
<div
|
||
key={i}
|
||
className="flex items-center justify-center gap-2 py-1 text-ui-body text-muted-foreground"
|
||
>
|
||
<span className="text-warning">◷</span>
|
||
<span>{block.text}</span>
|
||
</div>
|
||
);
|
||
}
|
||
if (block.kind === "progress") {
|
||
return (
|
||
<div key={i} className="flex w-fit max-w-full justify-start">
|
||
<div
|
||
data-testid="message-bot-bubble"
|
||
className="chat-bubble-bot max-w-full rounded-[24px] px-5 py-3.5 text-ui-body leading-[1.62] text-foreground/95"
|
||
dir="auto"
|
||
>
|
||
<ChatMarkdown streaming>{block.text}</ChatMarkdown>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
if (block.kind === "subagent") {
|
||
const running = block.status === "running";
|
||
const failed = block.status === "failed";
|
||
return (
|
||
<div
|
||
key={i}
|
||
className="apple-glass-card w-[min(460px,95%)] rounded-[22px] px-5 py-4 transition-all"
|
||
>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="flex items-center gap-2.5 min-w-0">
|
||
<div
|
||
className={`relative flex h-2.5 w-2.5 shrink-0 ${running ? "animate-pulse" : ""}`}
|
||
>
|
||
<span
|
||
className={`relative inline-flex rounded-full h-2.5 w-2.5 ${failed ? "bg-red-500" : running ? "bg-amber-400" : "bg-emerald-400"}`}
|
||
></span>
|
||
</div>
|
||
<span className="text-ui-body font-semibold text-foreground truncate" dir="auto">
|
||
{block.name}
|
||
</span>
|
||
</div>
|
||
<span
|
||
className={`rounded-full px-3 py-0.5 text-ui-body-sm font-medium border ${
|
||
failed
|
||
? "border-destructive/30 bg-destructive/15 text-destructive"
|
||
: running
|
||
? "border-warning/30 bg-warning/15 text-warning"
|
||
: "border-success/30 bg-success/15 text-success"
|
||
}`}
|
||
style={{
|
||
animation: running ? "rkPulse 1.2s ease-in-out infinite" : undefined,
|
||
}}
|
||
>
|
||
{running ? t`subagent` : block.status}
|
||
</span>
|
||
</div>
|
||
<div className="mt-2 text-ui-body text-muted-foreground">{block.task}</div>
|
||
{block.progress || block.result ? (
|
||
<div className="mt-2.5 text-[14.5px] leading-[1.5] text-foreground/75">
|
||
<ChatMarkdown streaming={running}>
|
||
{block.result || block.progress || ""}
|
||
</ChatMarkdown>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
if (block.kind === "project_created") {
|
||
return (
|
||
<ProjectCreatedCard
|
||
key={i}
|
||
name={block.name}
|
||
memberCount={block.memberCount}
|
||
onClick={() => onOpenGroup(block.groupId)}
|
||
/>
|
||
);
|
||
}
|
||
if (block.kind === "child_bot") {
|
||
const removed = block.status === "deleted" || block.status === "archived";
|
||
return (
|
||
<button
|
||
key={i}
|
||
type="button"
|
||
disabled={removed}
|
||
onClick={() => onOpenBot(block.botId)}
|
||
className="w-[min(340px,90%)] rounded-[18px] border border-border bg-muted px-[18px] py-4 text-start disabled:opacity-60"
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-ui-body font-medium text-foreground" dir="auto">
|
||
{block.name}
|
||
</span>
|
||
<span
|
||
className={`rounded-full px-[11px] py-1 text-ui-body-sm ${
|
||
removed ? "bg-destructive/15 text-destructive" : "bg-success/15 text-success"
|
||
}`}
|
||
>
|
||
{block.status === "archived" ? (
|
||
<Trans>archived</Trans>
|
||
) : block.status === "deleted" ? (
|
||
<Trans>deleted</Trans>
|
||
) : (
|
||
<Trans>bot</Trans>
|
||
)}
|
||
</span>
|
||
</div>
|
||
<div className="mt-2 text-[14.5px] leading-[1.5] text-foreground/75" dir="auto">
|
||
{removed
|
||
? block.status === "archived"
|
||
? t`Archived. Chat, memory, and files kept.`
|
||
: t`Removed with chat, computer, and memory.`
|
||
: block.title || t`Opened its thread.`}
|
||
</div>
|
||
</button>
|
||
);
|
||
}
|
||
if (block.kind === "choice") {
|
||
const botId = "botId" in artifactTarget ? artifactTarget.botId : message.botId;
|
||
if (!botId) return null;
|
||
return <ChoiceCard key={i} botId={botId} block={block} onBotChanged={onBotChanged} />;
|
||
}
|
||
if (block.kind === "app_connect") {
|
||
const botId = "botId" in artifactTarget ? artifactTarget.botId : message.botId;
|
||
if (!botId) return null;
|
||
return (
|
||
<div key={i} className="flex justify-start py-1">
|
||
<AppConnectCard botId={botId} block={block} />
|
||
</div>
|
||
);
|
||
}
|
||
if (block.kind === "chart") {
|
||
return (
|
||
<div key={i} className="flex justify-start">
|
||
<ChartBlockView name={block.name} spec={block.spec} data={block.data} />
|
||
</div>
|
||
);
|
||
}
|
||
if (block.kind === "mcp_approval") {
|
||
return (
|
||
<div key={i} className="flex justify-start">
|
||
<McpApprovalCard
|
||
botId={"botId" in artifactTarget ? artifactTarget.botId : message.botId}
|
||
name={block.name}
|
||
serverId={block.serverId}
|
||
transport={block.transport}
|
||
endpoint={block.endpoint}
|
||
needsOAuth={block.needsOAuth}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
if (block.kind === "image") {
|
||
return (
|
||
<div
|
||
key={i}
|
||
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}
|
||
>
|
||
<ArtifactImage
|
||
target={artifactTarget}
|
||
artifactId={block.artifactId}
|
||
name={block.name}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
if (block.kind === "file") {
|
||
return (
|
||
<div
|
||
key={i}
|
||
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}
|
||
>
|
||
<ArtifactFileCard
|
||
target={artifactTarget}
|
||
artifactId={block.artifactId}
|
||
name={block.name}
|
||
mimeType={block.mimeType}
|
||
size={block.size}
|
||
onOpenBeside={
|
||
onOpenArtifact
|
||
? () =>
|
||
onOpenArtifact({
|
||
messageId: message.id,
|
||
artifactId: block.artifactId,
|
||
name: block.name,
|
||
mimeType: block.mimeType,
|
||
})
|
||
: undefined
|
||
}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
if (block.kind === "text" && message.role === "user") {
|
||
return (
|
||
<div key={i} className="flex w-fit max-w-full justify-end">
|
||
<div
|
||
data-testid="message-user-bubble"
|
||
className={
|
||
encounterMode
|
||
? "max-w-full whitespace-pre-wrap wrap-anywhere rounded-2xl bg-chat-user px-3.5 py-2 text-ui-body-sm leading-relaxed text-chat-user-foreground"
|
||
: "chat-bubble-user max-w-full whitespace-pre-wrap wrap-anywhere rounded-[24px] px-6 py-4 text-[17px] leading-[1.62] text-chat-user-foreground shadow-md"
|
||
}
|
||
dir="auto"
|
||
>
|
||
{block.text}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
if (block.kind === "text") {
|
||
return (
|
||
<div key={i} className="flex w-fit max-w-full justify-start">
|
||
<div
|
||
data-testid="message-bot-bubble"
|
||
className={
|
||
encounterMode
|
||
? "max-w-full space-y-2 border-s-2 border-emerald-500/35 ps-3 text-ui-body-sm leading-relaxed text-foreground/90"
|
||
: "chat-bubble-bot max-w-full space-y-3 rounded-[24px] px-6 py-4 text-[17.5px] leading-[1.85] text-foreground/95"
|
||
}
|
||
dir="auto"
|
||
>
|
||
<ChatMarkdown>{block.text}</ChatMarkdown>
|
||
<div className="mt-3 pt-2.5 flex items-center justify-between border-t border-border text-ui-label text-muted-foreground/75">
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (navigator.clipboard) {
|
||
navigator.clipboard.writeText(block.text);
|
||
}
|
||
}}
|
||
className="inline-flex items-center gap-1.5 hover:text-foreground transition-colors cursor-pointer text-ui-body-sm font-medium"
|
||
title={t`Copy reply`}
|
||
>
|
||
<Copy size={13.5} />
|
||
<span>{t`Copy`}</span>
|
||
</button>
|
||
{voiceReady ? (
|
||
<button
|
||
type="button"
|
||
aria-label={speaking ? t`Stop speaking` : t`Speak this reply`}
|
||
onClick={onSpeak}
|
||
className="inline-flex items-center gap-1.5 hover:text-foreground transition-colors cursor-pointer text-ui-body-sm font-medium"
|
||
>
|
||
<Volume2 size={13.5} />
|
||
<span>{speaking ? t`Stop` : t`Read aloud`}</span>
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
<span className="tabular-nums font-mono text-ui-label text-muted-foreground">
|
||
{new Date(message.createdAt).toLocaleTimeString([], {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
})}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
if (block.kind === "card") {
|
||
return (
|
||
<div key={i} className="flex justify-start">
|
||
<div className="flex flex-col gap-2 rounded-[20px] bg-muted px-5 py-4">
|
||
{block.lines.map((line) => (
|
||
<div key={line.k} className="flex items-baseline gap-2.5 text-ui-body">
|
||
<span className="text-success">✓</span>
|
||
<span className="font-semibold text-foreground">{line.k}</span>
|
||
<span className="text-muted-foreground">→</span>
|
||
<span>{line.v}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
if (block.kind === "ask") {
|
||
return (
|
||
<AskCard
|
||
key={i}
|
||
block={block}
|
||
canAnswer={canAnswer}
|
||
onAnswer={(text) => onAnswer(message, text)}
|
||
/>
|
||
);
|
||
}
|
||
if (block.kind === "cloud_agent") return <CloudAgentCard key={i} block={block} />;
|
||
if (block.kind === "skill_draft") {
|
||
return (
|
||
<div key={i} className="flex justify-start">
|
||
<SkillDraftCard block={block} onRefresh={onRefresh} onAddRoutine={onAddRoutine} />
|
||
</div>
|
||
);
|
||
}
|
||
if (block.kind === "computer") {
|
||
return (
|
||
<div
|
||
key={i}
|
||
className="w-[340px] rounded-[18px] border border-border bg-muted px-[18px] py-4"
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-ui-body font-medium text-foreground">
|
||
<Trans>Computer</Trans>
|
||
</span>
|
||
<span
|
||
className={
|
||
block.state === "Needs you"
|
||
? "rounded-full bg-warning/15 px-[11px] py-1 text-ui-body-sm text-warning"
|
||
: "rounded-full bg-success/15 px-[11px] py-1 text-ui-body-sm text-success"
|
||
}
|
||
>
|
||
{block.state}
|
||
</span>
|
||
</div>
|
||
<div className="my-2.5 text-[14.5px] leading-[1.5] text-foreground/75">
|
||
<ChatMarkdown>{block.text}</ChatMarkdown>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
return null;
|
||
})}
|
||
</>
|
||
);
|
||
});
|
||
|
||
function embeddableScreenUrl(url: string | null): string | null {
|
||
if (!url) return null;
|
||
try {
|
||
const parsed = new URL(url, window.location.href);
|
||
const page = new URL(window.location.href);
|
||
const local = parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost";
|
||
const pagePort = page.port || (page.protocol === "https:" ? "443" : "80");
|
||
if (local && parsed.port && parsed.port !== pagePort) {
|
||
return null;
|
||
}
|
||
return parsed.toString();
|
||
} catch {
|
||
return url;
|
||
}
|
||
}
|
||
|
||
function screenIframeSandbox(url: string | null) {
|
||
if (!url) return undefined;
|
||
try {
|
||
return new URL(url, window.location.href).pathname.startsWith("/novnc/")
|
||
? "allow-scripts allow-pointer-lock"
|
||
: undefined;
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
function DesktopKindEmptyState({ className }: { className?: string }) {
|
||
return (
|
||
<div className={className}>
|
||
<Trans>
|
||
This bot runs on this computer, not a Linux desktop. Shell and files use your home folder.
|
||
</Trans>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function computerPlaceholder(
|
||
state: ComputerStatus["state"] | undefined,
|
||
booting: boolean,
|
||
label: string,
|
||
) {
|
||
if (state === "booting" || booting) return t`Booting live desktop…`;
|
||
if (state === "running") return label;
|
||
if (state === "suspended") return t`Computer is asleep. Open it to wake.`;
|
||
if (state === "error") return t`Computer failed to boot`;
|
||
return t`Computer is stopped`;
|
||
}
|
||
|
||
function computerLabel(mode: ComputerStatus["mode"] | undefined, botName: string) {
|
||
return mode === "dedicated" ? t`${botName}’s computer` : t`Team Computer`;
|
||
}
|
||
|
||
function newClientNonce(): string {
|
||
const webCrypto = globalThis.crypto;
|
||
if (webCrypto && typeof webCrypto.randomUUID === "function") {
|
||
return webCrypto.randomUUID();
|
||
}
|
||
return `m-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||
}
|
||
|
||
function readFileAsBase64(file: File): Promise<string> {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
const result = typeof reader.result === "string" ? reader.result : "";
|
||
const base64 = result.includes(",") ? (result.split(",")[1] ?? "") : result;
|
||
resolve(base64);
|
||
};
|
||
reader.onerror = () => reject(reader.error ?? new Error("Failed to read file"));
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|