Compare commits

..

25 Commits

Author SHA1 Message Date
Claude
f359da0bf9
Merge main conversation: land in it, led by the main bot
The only conflict was the onboarding imports: onboarding now creates the
main bot, writes the profile and lands in the main conversation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 00:26:31 +00:00
Claude
311551da04
feat: land in the main conversation, led by the main bot
After onboarding the user ended up in a 1:1 chat with "Chief", and the
"Hauptthread" was a group the web client created and found by name. The
main bot had no conversation it actually led, and addressing another bot
there did nothing unless the user had added it as a member first.

Now the main conversation is a real place:

- bots.ensureMain creates or returns the main bot. Bootstrap and space
  navigation return `main` ({ botId, groupId, spaceId }) once the main
  bot exists; reading navigation never creates a bot.
- The main bot leads the main conversation and answers when nobody is
  addressed: message routing, follow-ups, reactions and clearing the
  thread fall back to the group's lead instead of its first member.
- Addressing a same-space bot that is not a member yet, by @Name or a
  mention chip, makes it join before the message is routed, so it
  answers in the conversation. Specialists from other spaces do not join.
- The main bot cannot be archived or deleted, the main conversation
  cannot be archived or deleted or have its members edited, and child
  bots never dissolve it. Renaming the main bot renames the conversation.
- Web: onboarding uses bots.ensureMain and finishes in the main
  conversation. The main conversation is the first sidebar entry in every
  space, shown with the main bot's avatar and name; from another space it
  switches back to the home space. The main bot is not listed again among
  the bots, its 1:1 thread stays reachable by URL. The client no longer
  creates or detects the "Hauptthread" by name, refetches a group when it
  changes, and shows the speaker of any of the user's bots in a group.
- Onboarding no longer resets the step when its bootstrap resolves after
  the user has already moved on, and new spaces open in the app instead
  of walking onboarding again.

Tests: group mention routing with a default responder, lead follow-ups,
child-bot guards, a journey for the main conversation (lead answers,
addressed bot joins, guards, rename), authorization for bots.ensureMain,
and an E2E spec that lands in the main conversation, gets an answer from
the lead, sees an addressed bot join, and reaches the conversation from
another space. Existing E2E specs start in the main bot's thread through
a helper or use the main conversation where that is what they test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 00:24:16 +00:00
Claude
5fa5779b6b
feat(adapters): run profile consolidation on the fast tier when one is mapped
Fact extraction is short structured work, so consolidation asks the model
resolver for the fast tier. The resolver takes a per-call tier and, when the
space has not mapped it, keeps the bot's own tier or model as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-02 23:57:36 +00:00
Claude
04b7513e57
Merge model tiers: fast, standard and code per space for bots and helpers
Conflicts were additive: duplicate and createBot carry both the tool policy
and the tier, and the bot panel saves both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-02 23:55:53 +00:00
Claude
848abc757a
Merge capabilities: least-privilege builtin tools per bot and schedules on behalf
The per-bot tool policy now carries the main bot's own tools: delegate_task
and update_user_profile are the main-only builtins, so the separate filters
from the profile and delegation branches are gone. One set of exposed tool
names drives both the executor's call guard and the prompt. The schedule
tool says new routines default to the user's time zone, which makes the
extra prompt line unnecessary. Tests that relied on an empty tool list
meaning every tool, or on the tool's own refusal text, now pass the tool
explicitly or expect the executor's refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-02 23:53:19 +00:00
Claude
5112d0e516
test(adapters): give the consolidation unit tests the profile lock
The write transaction now takes the per-user profile lock, so the mocked
client answers the lock query, and the first case checks the lock is taken.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-02 23:26:49 +00:00
Claude
9eee248877
feat(capabilities): least-privilege builtin tools per bot and schedules on behalf
Every bot saw every builtin tool, and nothing outside the runtime's own
declaration kept a run to the tools it was given: the executor ran any tool a
runtime asked for, and the Pi runtime exposed all builtins whenever a caller
passed an empty tool list, which the safety checker, the history summarizer
and the team chat judge do on purpose. A specialist could not be kept to the
tools of its lane, bots had no capability tags, and the main bot could only
schedule routines for itself.

- A bot can carry a builtin tool allowlist. Its runs see only the allowlisted
  builtins, and an empty allowlist means none. MAIN_ONLY_BUILTIN_TOOLS (empty
  until the user profile and delegation tools land) never reach other bots.
  applyTool refuses any tool the run did not expose, and an empty tool list
  now means no tools for the Pi runtime and its subagents.
- A bot spawned by a restricted bot inherits the restriction and a duplicate
  keeps it, so neither path widens a bot's tools.
- bots.create and bots.update accept tags, restrictBuiltinTools and
  allowedBuiltinTools; the router rejects names that are not builtin tools.
  bots.builtinTools lists the names a bot's allowlist can use.
- Bot settings get a Tags field and a switch with a tool checklist under
  Advanced.
- The main bot's schedule_create takes an optional bot: one of the user's
  bots in any space the user still belongs to. The routine belongs to that
  bot in its space and wakes in the main conversation when the bot is a
  member there, otherwise in its 1:1 thread. Routines record the bot that
  scheduled them (additive migration), so schedule_list and schedule_cancel
  also cover exactly the routines a bot scheduled for others. Cancelling by
  a name that matches several routines is refused.

Tested with unit tests for the per-bot filter, the applyTool guard, the empty
tool list, and on-behalf resolution, creation, listing and cancelling; with
Postgres journeys for on-behalf routines waking across spaces and in the main
conversation and for allowlist enforcement through the scripted runtime; and
with a web E2E for the settings section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-02 23:24:56 +00:00
Claude
84a95f0a3b
Merge inbox: what specialists need from the user, in the main conversation
Conflicts were additive: the inbox route, contract and authorization cases sit
next to the delegated task ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-02 23:11:07 +00:00
Claude
af6d5bb0ea
fix(adapters): keep profile writes out of replies to other owners' agents
A run woken over an agent connection hears another owner's bot, not the
user, so update_user_profile is withheld there: nothing such a conversation
says can become a fact about the user. Detection moves ahead of tool
selection; the reduced user block uses the same flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-02 23:09:17 +00:00
Claude
516d1d4eb6
fix(adapters): write learned facts under the profile's lock and limit
Profile consolidation wrote facts beside the user profile repository: without
its per-user lock, its duplicate check at write time, or its limit of 500
facts. A long-running profile could grow past the limit and then refuse the
user's own additions. Consolidation now takes the same lock, drops facts the
profile gained since the run read it, and keeps only what still fits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-02 23:07:44 +00:00
Claude
728d78001a
Merge user profile: readable, writable and forgettable user model
Both branches gave the main bot its own tools, so the main-only filter now
covers delegate_task and update_user_profile, and the main bot's role test
expects the profile tool instead of remember.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-02 23:05:28 +00:00
Claude
408db28330
feat(inbox): show what specialists need from the user in the main conversation
Specialists ask questions, request approvals and the computer screen in
their own threads, and the user only finds out by opening each thread. With
the main conversation as the single entry point, those requests have to
reach the user there, with the specialist as the visible sender.

- inbox.list returns, for the current space and the user's own bots, pending
  asks and approvals (bot messages with a pending ask block whose run waits
  for input), takeover requests, and unread specialist threads. Asks already
  in the main conversation are left out; takeovers stay because their
  controls live in the bot's own thread. Each item carries the bot, the
  thread target, run and message ids, and the ask block for asks and
  approvals.
- The main conversation shows the items above the composer. Asks and
  approvals are answered in place through threads.answer with the source
  thread target; takeovers and unread threads open the bot's thread. The
  list refreshes when the bots' status, unread state or newest message
  changes, every 15 seconds, and after an answer.
- The inbox shrinks and scrolls so it never pushes the composer out of view.
  The transcript now clips its scroller: with no room left in the column,
  the scroller's padding used to spill over whatever sat below it.
- The ask block schema is extracted from MessageBlock so the inbox contract
  reuses it, and AskCard accepts a className.

Tests: unit tests for the item builder and the component, a Postgres suite
for the query (added to the harness list), inbox cases in the authorization
suite, and an E2E spec in which a specialist asks in its own thread and the
user answers from the main conversation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-02 23:01:33 +00:00
Claude
d239ebec05
fix(adapters): show the main agent the specialists it can delegate to
The main conversation is a group run, and group runs no longer list the
teammate directory, so the main agent could not see the names of the
specialists delegate_task expects. Runs that have delegate_task now list
the user's specialists across the spaces they belong to, those next to the
conversation first and others marked with their space, and that list
replaces the teammate directory so no bot appears twice. It also says when
to prefer delegate_task over handoff_to_bot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-02 22:50:11 +00:00
Claude
ef6b469df1
Merge delegation: the main agent hands work to specialists as tracked tasks
Conflicts were only additive wiring next to the profile consolidation hook.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-02 22:48:04 +00:00
Claude
04f2109fc7
feat(delegation): main bot hands work to specialists as tracked tasks
The main agent had no way to give a specialist a piece of work and keep
track of it. message_bot is a fire-and-forget peer message whose work stays
hidden from the user, and handoff_to_bot ends the main bot's turn and only
works between members of one group. Delegation now runs the specialist's
work where the user follows the main agent and brings the result back so
the main agent can close the task.

- delegate_task({bot, title, brief, deadline?}) is offered to the main bot
  only and refused unless the caller leads the main conversation. It
  resolves the specialist by id or name across the user's spaces, creates a
  DelegatedTask and posts a task card in the main conversation.
- A specialist from the main conversation's space joins it and works there
  in a run with the new trigger "delegation", which the peer filters do not
  hide, so its progress, asks and approvals appear in the main conversation
  with the specialist as sender. A specialist from another space works in
  its own chat; its written result is posted into the main conversation as
  its message.
- The executor mirrors the run on its task (running, waiting, done, failed,
  cancelled) after the run starts and when each attempt ends, and updates
  the card in place through thread.message.updated. Done and failed tasks
  wake the main bot in the main conversation with the result. The job
  reconciler settles tasks whose run ended without the executor (stop,
  clear, archive, deletion, crash).
- tasks.list({threadId?, status?}) returns the caller's tasks for the
  current space.
- The task card shows who, what and status with Lingui labels; the brief
  opens on demand. The message_bot card no longer shows the recipient's
  unrelated overall status or hard-coded German copy.
- The scripted runtime understands "delegate to the bot named X: ..." and
  the task wake prompt; activity rows name delegation runs by task title.

Tests: unit tests for the prompts, status mapping, card, reducer, tool
selection and scripted runtime; a Postgres suite for same-space, group,
other-space, ask and answer, failure, stop and replay; tasks.list in the
authorization suite; and an end-to-end spec for the whole flow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-02 22:41:38 +00:00
Claude
492de63c77
feat(profile): readable, writable and forgettable user model
The main agent is meant to know the user, but nothing could read or change
the user model the foundation added. Onboarding pasted the user's answers
into the first bot's instructions as a "User Context" block, and the user
had no way to see or remove what their bots knew about them.

This makes the user model a user-controlled resource:

- db: a profile repository scoped by user. One atomic update writes the
  account name, profile fields, new facts (deduplicated by normalized
  text, capped per user), fact edits including per-space visibility, and
  confirmations. Health and values facts wait for confirmation unless the
  user wrote them. Forgetting hard-deletes facts by id, category or all,
  clears the summary that may restate them and marks earlier chats as
  consolidated so they cannot teach them again; forgetting everything also
  clears the profile fields. Writes take a per-user advisory lock.
- api/contracts: profile.get, profile.update and profile.forget. Unknown
  fact ids are a 404 on update and ignored on forget; spaces outside the
  user's memberships are rejected.
- adapters: an update_user_profile tool for the main bot only, hidden from
  specialists and shared messaging channels and refused if called anyway.
  Facts carry source "conversation" and the bot, thread and message they
  came from. The scripted runtime records "learn about me: [category:]
  statement" for tests.
- web: onboarding writes the name, time zone (browser default, now
  prefilled and editable), locale, chosen language, communication style
  and the hobby and work-style answers to the profile instead of
  overwriting the bot's instructions, and the harvested memory section no
  longer repeats them. A new "About you" settings section lists profile
  fields and facts by category with their origin, confirms pending facts,
  and forgets one fact or everything.

Tests cover the contracts, the repository (unit and PostgreSQL), tool
selection and refusal, the scripted trigger, the onboarding mapping and
view helpers, an API suite that runs the tool through the executor with a
real main bot, authorization for the new procedures, and an E2E spec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-02 22:41:07 +00:00
Claude
5834e46045
fix(adapters): keep the user's summary and facts out of replies to other owners' agents
An agent connection wakes a bot with another owner's message, and the reply
goes back to that owner. Such runs now get only the basics of the user block
(name, time zone, locale, language, style), like runs in shared messaging
channels, so confirmed facts such as health never reach another person's
agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-02 22:25:21 +00:00
Claude
e085dc11a9
fix(adapters): state the consolidation time in the user's zone; name the memory migration after its content
Profile consolidation now passes the profile's time zone to the shared time
instruction, which takes the zone since every run states the time in the
user's zone. The semantic memory migration is renamed after what it creates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-02 22:25:21 +00:00
Claude
70e376f041
Merge profile consolidation and built-in semantic memory
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-02 22:22:54 +00:00
Claude
57e2343032
Merge prompt assembly: identity, user block and lead groups in every run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-02 22:22:54 +00:00
Claude
f28f9b7d55
feat(models): map model tiers per space and let bots and helpers ask for one
A space can now map the tiers fast, standard and code to connected models,
including several models served by one OpenAI-compatible router endpoint
(one credential, many model ids). A bot can ask for a tier, and a single
run_subagent call can ask for one. A tier with no model mapped falls back
to the usual model choice, so nothing breaks before tiers are set up.

- db: SpaceModelTier repo (list, find, set or clear); tier mappings are
  copied into a new space like model and voice preferences; createBot
  carries a tier, so duplicates keep it and spawned bots inherit it.
- contracts/api: models.tiers.list and models.tiers.set;
  models.probeOpenAiCompatible can list a connected server's models by
  credential id with its stored address and key; bots.update accepts
  modelTier and keeps a pinned model and a tier mutually exclusive, so a
  model picked on a surface that knows no tiers (mobile) is never silently
  overridden. Thinking-level validation follows the tier's model. AI
  consent status discloses tier models; desktop local settings allow the
  tier procedures.
- adapters: selectConfiguredModel runs a mapped tier ahead of the pinned
  model and the space default; the executor resolves bot tiers for runs
  and model lookups, and helper tiers through resolveModelTier.
  run_subagent takes model_tier; an explicit model wins over it.
- web: a collapsed Tiers section in model settings (shown once a model is
  connected) and mapped tiers as choices in the bot Model select.
- docs: tiers in self-host model setup.
- tests: resolution order and fallbacks (selection, executor, Pi helper),
  repo, API and contract units, authorization and offline Pi Postgres
  suites, and a model-tiers web E2E spec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-02 22:07:32 +00:00
Claude
20a900c505
feat(memory): learn the user profile from conversations; add built-in semantic memory
Why: the user profile only knew what onboarding wrote, so people had to repeat themselves,
and semantic recall needed a hosted or separately run memory service. The profile now grows
from everyday conversations, and recall can run on the user's own server with a local
embeddings endpoint (for example llama.cpp). Both stay optional and are tested offline.

What:
- adapter-kit: EmbeddingProvider contract (describe, embed) and the profile.consolidate job
  (payload schema and a replace-keyed job factory, one pending job per user).
- adapters: OpenAI-compatible embeddings (POST {baseUrl}/embeddings through the guarded
  OpenAI-compatible fetch, batching, bounded responses, clear errors), a deterministic
  emulator, an in-process /v1/embeddings server emulator, and a shared conformance suite.
- adapters: "builtin" semantic memory stored in Postgres (semantic_memory_items with Float[]
  embeddings, cosine ranking in process, bot/shared/history scopes matching the existing
  providers), registered in the memory provider factory and configured through
  memory.connectProvider with an embeddings URL, model and optional API key. Private
  endpoints require the deployment owner, like the other local memory providers.
- adapters: profile consolidation reads the user's settled app conversations since
  UserProfile.consolidatedAt (never messaging channels), asks the main bot's model for strict
  JSON facts and an updated summary, drops restatements (embedding cosine when built-in
  memory is configured, normalized text otherwise), stores sensitive categories as pending,
  and advances the cursor with a compare-and-set. A reconciler hook enqueues it at most daily
  per active user; it skips under the scripted runtime and without a usable model.
- db: additive migration 20261002210400_ws4_semantic_memory.
- web: a "Built-in" option in Memory settings; AI data sharing names the embeddings server.

Tests: new embedding conformance, OpenAI-compatible wire, built-in provider and consolidation
unit tests; new Postgres suites for built-in memory (including a real loopback embeddings
server through the memory provider resolver) and for consolidation and its scheduling query,
both added to the integration harness. The settings E2E spec now opens the new form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-02 21:45:54 +00:00
Claude
e71c16050d
feat(adapters): every run knows who it is and who the user is, in the user's time zone
Runs dropped the bot's name, title and description whenever custom
instructions existed, always stated the time in UTC, and knew the user only
through free-form durable memory. Groups with a lead, like the main
conversation, still got the peer-group lines that forbid handing work back.
And every group follow-up run failed during setup: the inbound handoff lookup
selected a `bot` relation that Message does not have, which Prisma rejects.

- Instructions now open with the bot's identity, then its own instructions,
  a main-agent role paragraph for the main bot, the current time in the
  user's time zone with its offset (UTC when unknown), and a `<user>` block
  built from loadUserContext: profile fields and the active facts visible in
  the run's space, escaped, clipped and framed as data rather than
  instructions. Runs that reply in a shared messaging channel get only the
  name, zone, locale, language and style, matching how those runs already
  leave out durable memory.
- run_subagent helpers receive the identity, time and user block through a
  new optional AgentRunRequest.subagentContext instead of nothing.
- Groups with an active lead get lead and specialist lines and matching
  inbound handoff instructions. Members may hand a stage back to the lead and
  the lead may delegate the next stage; the bounce rules still apply between
  members. Ordinary groups keep today's lines.
- The teammate directory is rendered only when the run has message_bot, which
  group runs never have, and tool guidance names a tool only when the run has
  it.
- The auto-review judge, history compaction and the team chat judge read the
  time in the user's zone, and schedule_create defaults to it.
- The inbound handoff sender is read from the handoff block and looked up
  among the user's bots; a Postgres test covers the follow-up run, the prompt
  it receives, and handing the result back to the lead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-02 21:39:39 +00:00
Claude
8cfbca51b2
Merge origin/main into claude/phase1-main-agent
Brings in the aurora voice stage so the Phase 1 workstreams integrate on current main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-02 20:59:40 +00:00
cynarAI
86723f0f6f
feat(db): main agent, main conversation, user model and delegated task foundation
The product needs one main agent that leads a main conversation, knows the
user across spaces, and hands work to visible specialists. Today none of
that exists in the data model: "Chief" is a client-side naming convention,
the "Hauptthread" is a group the web client creates by name, and nothing
stores what the agents know about the user.

This adds the shared foundation the Phase 1 features build on:

- Bot.kind ("main" | "specialist", one main bot per user) plus capability
  fields (tags, builtin tool allowlist, model tier).
- ChatGroup.kind ("group" | "main", one main conversation per user) and
  ChatGroup.leadBotId.
- UserProfile and UserFact with category, confidence, source, consent
  status and per-space visibility; embeddings are stored as float arrays
  because none of the Postgres images ship pgvector.
- DelegatedTask for work the main bot hands to a specialist.
- SpaceModelTier to map a model tier to a connected model per space.

The migration backfills each user's onboarding bot in the home space as the
main bot and turns an existing "Hauptthread" group containing it into the
main conversation. CHECK and foreign key constraints on existing tables are
added NOT VALID and validated in a separate migration, as elsewhere in this
repo.

New db helpers ensure the main bot and the main conversation (idempotent and
race-safe, repairing a lost lead), add same-space specialists to the main
conversation and announce it with a group.updated event, and load the user
context a run may see. The main conversation is exempt from the two-member
floor and cannot have its members replaced through a group update. The
contracts expose the new fields and the user model and delegated task
schemas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-02 20:49:36 +00:00
19 changed files with 162 additions and 433 deletions

View File

@ -161,19 +161,16 @@ jobs:
provenance: mode=max
sbom: true
- name: Record the architecture digest
id: digest
env:
DIGEST: ${{ steps.build.outputs.digest }}
run: |
set -euo pipefail
digest_dir="${RUNNER_TEMP}/digest"
mkdir -p "$digest_dir"
touch "${digest_dir}/${DIGEST#sha256:}"
echo "dir=${digest_dir}" >> "$GITHUB_OUTPUT"
mkdir -p /tmp/digests
touch "/tmp/digests/${DIGEST#sha256:}"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: digest-${{ matrix.name }}-${{ matrix.arch }}
path: ${{ steps.digest.outputs.dir }}/*
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
@ -196,13 +193,10 @@ jobs:
- name: updater
- name: computer
steps:
- name: Prepare an isolated digest directory
id: digest_dir
run: echo "path=${RUNNER_TEMP}/digests" >> "$GITHUB_OUTPUT"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: digest-${{ matrix.name }}-*
path: ${{ steps.digest_dir.outputs.path }}
path: /tmp/digests
merge-multiple: true
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to GHCR
@ -229,7 +223,7 @@ jobs:
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }}
- name: Assemble and verify the manifest
id: manifest
working-directory: ${{ steps.digest_dir.outputs.path }}
working-directory: /tmp/digests
env:
IMAGE: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
TAGS: ${{ steps.meta.outputs.tags }}
@ -256,23 +250,7 @@ jobs:
grep -Fqx "$want" <<< "$platforms" || { echo "$first_tag is missing $want" >&2; exit 1; }
done
echo "digest=$(docker buildx imagetools inspect "$first_tag" --format '{{.Manifest.Digest}}')" >> "$GITHUB_OUTPUT"
# Owner type is not exposed on the github context; resolve it from the repository metadata API.
- name: Resolve registry attestation eligibility
id: attestation
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
owner_type="$(gh api "repos/${GITHUB_REPOSITORY}" --jq -r '.owner.type')"
private="$(gh api "repos/${GITHUB_REPOSITORY}" --jq -r '.private')"
if [[ "$owner_type" == "User" && "$private" == "true" ]]; then
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "Skipping registry attestation: unavailable for private repositories on personal accounts."
else
echo "enabled=true" >> "$GITHUB_OUTPUT"
fi
- name: Attest the published image
if: steps.attestation.outputs.enabled == 'true'
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}

View File

@ -10,7 +10,6 @@
- Keep code simple: reuse existing primitives and one source of truth, remove duplication and unused flexibility, and avoid speculative abstractions. Add an interface when it protects a real external or platform boundary, not for its own sake.
- Treat auth, secret handling, sandbox boundaries, host commands, and integrations as security-sensitive. Keep tests deterministic and offline by default.
- The desktop Playwright suite (`pnpm --filter @rakazo/desktop test:e2e`) opens real Electron windows and steals focus on macOS. Do not run it on a maintainer's machine as routine verification; run the unit tests locally and let CI's virtual-display job run the e2e on push.
- Work autonomously. Make routine calls yourself, such as opening or reopening a pull request, linking CI screenshots, or fixing gaps you found that belong to the task, then verify and report the outcome. Ask the maintainer only before irreversible actions or for product decisions without a sensible default.
- After creating a pull request, stay with it until CI and automated review bots have finished; passing checks alone do not mean the review is complete. Follow `.agents/skills/pr-watch/SKILL.md`: run its `pr-digest --watch` helper as a single backgrounded call that blocks until the checks on the current head commit are terminal, then act on the verdict it prints. Do not poll in the foreground on a timer, which mostly buys repeated snapshots of an unchanged PR and dies at the tool timeout. Address every actionable issue, push the fixes, and repeat until no actionable feedback remains. Do not merge while review bots are still pending or review issues remain unresolved.
- PR descriptions say why the change exists, what changed, and how it was tested. Write Why as the product reason (what was wrong and what this changes); never "Elie wants" or a third-person briefing of a named person; the maintainer is the author.
- Commit messages, PR descriptions, issues, and review replies are public too. Describe test results in words instead of pasting tool output, and never include anything that identifies a person, machine, or account: local paths, usernames, hostnames, signing identities, legal entity names, Apple Team IDs, API key IDs, issuer or tenant IDs, account emails, or the accounts, machines, and files that secret values came from. Naming the secrets a workflow reads is fine; use placeholders when a value must be shown.

View File

@ -1,264 +0,0 @@
import { expect, type Locator, type Page, test } from "@playwright/test";
import {
captureScreenshot,
completeOnboarding,
createNamedBot,
openNewBot,
rpc,
signup,
waitForChatComposer,
} from "./helpers";
// WCAG AA for body text.
const MIN_CONTRAST = 4.5;
test.use({ viewport: { width: 1280, height: 1000 } });
/**
* Texts in `locator` below WCAG AA against the surface they are painted on. With `inside`, every
* visible text and typed value within it counts, except in disabled controls, which WCAG exempts,
* and in elements matching `skip`.
*/
function unreadableTexts(locator: Locator, inside: boolean, skip: string | null) {
return locator.evaluate(
(root, { inside, skip, min }) => {
const context = document
.createElement("canvas")
.getContext("2d", { willReadFrequently: true });
if (!context) throw new Error("No 2D canvas context");
// The canvas normalizes every CSS color syntax (oklab, color-mix, ...) to sRGB.
const rgba = (color: string) => {
context.clearRect(0, 0, 1, 1);
context.fillStyle = color;
context.fillRect(0, 0, 1, 1);
const [r = 0, g = 0, b = 0, a = 0] = context.getImageData(0, 0, 1, 1).data;
return [r / 255, g / 255, b / 255, a / 255] as const;
};
// One pixel of `color`, painted inside `from` and composited through every ancestor's
// background color and opacity onto the white canvas.
const paint = (color: readonly [number, number, number, number], from: Element) => {
let [r, g, b, a] = color;
for (let node: Element | null = from; node; node = node.parentElement) {
const style = getComputedStyle(node);
const [br, bg, bb, ba] = rgba(style.backgroundColor);
const alpha = a + ba * (1 - a);
if (alpha > 0) {
r = (r * a + br * ba * (1 - a)) / alpha;
g = (g * a + bg * ba * (1 - a)) / alpha;
b = (b * a + bb * ba * (1 - a)) / alpha;
}
a = alpha * Number(style.opacity);
}
return [r * a + 1 - a, g * a + 1 - a, b * a + 1 - a] as const;
};
const luminance = (rgb: readonly [number, number, number]) => {
const [r = 0, g = 0, b = 0] = rgb.map((channel) =>
channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4,
);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const contrast = (element: Element) => {
const text = luminance(paint(rgba(getComputedStyle(element).color), element));
const surface = luminance(paint([0, 0, 0, 0], element));
return (Math.max(text, surface) + 0.05) / (Math.min(text, surface) + 0.05);
};
// What the element itself shows: a typed value, or its own text nodes.
const ownText = (element: Element) => {
if (element instanceof HTMLTextAreaElement) return element.value;
if (element instanceof HTMLInputElement) {
return /^(text|search|url|email|tel|number|password)$/.test(element.type)
? element.value
: "";
}
return Array.from(element.childNodes, (node) =>
node.nodeType === Node.TEXT_NODE ? node.textContent : "",
)
.join("")
.trim();
};
const shown = (element: Element) => {
const { width, height } = element.getBoundingClientRect();
return width > 1 && height > 1 && element.checkVisibility();
};
const elements = inside
? [root, ...root.querySelectorAll("*")].filter(
(element) =>
ownText(element) &&
shown(element) &&
!element.closest(":disabled") &&
!(skip && element.closest(skip)),
)
: [root];
return elements.flatMap((element) => {
const ratio = contrast(element);
const text = ownText(element) || element.textContent?.trim() || "";
return ratio < min ? [`${ratio.toFixed(2)}:1 ${text.slice(0, 48)}`] : [];
});
},
{ inside, skip, min: MIN_CONTRAST },
);
}
async function expectReadable(
locator: Locator,
what: string,
{ inside = false, skip = null }: { inside?: boolean; skip?: string | null } = {},
) {
await expect(locator).toBeVisible();
await expect
.poll(() => unreadableTexts(locator, inside, skip), { message: `${what} is readable` })
.toEqual([]);
}
async function inEachTheme(page: Page, check: (theme: "light" | "dark") => Promise<void>) {
for (const theme of ["light", "dark"] as const) {
await page.emulateMedia({ colorScheme: theme });
await expect(page.locator("html")).toHaveAttribute("data-theme", theme);
await check(theme);
}
}
function runStatus(page: Page, groupId: string) {
return rpc<{ run?: { status: string } | null }>(page, "threads/get", { groupId }).then(
(snapshot) => snapshot.run?.status ?? "idle",
);
}
async function send(page: Page, groupId: string, text: string) {
await expect
.poll(() => runStatus(page, groupId), { timeout: 60_000 })
.not.toMatch(/^(queued|running)$/);
const composer = page.getByRole("combobox", { name: "Message" });
await composer.fill(text);
await composer.press("Enter");
}
test("conversation text stays readable in light and dark themes", async ({ page }, testInfo) => {
const stamp = Date.now();
await page.emulateMedia({ colorScheme: "light" });
await signup(page, `chat-contrast-${stamp}@rakazo.test`, "password12", "Contrast QA");
await completeOnboarding(page, undefined, { mainConversation: true });
const groupId = new URL(page.url()).pathname.split("/").at(-1)!;
await rpc(page, "bots/create", {
name: "Researcher",
title: "",
description: "",
instructions: "",
notifyOnFinish: true,
});
await page.reload();
await waitForChatComposer(page);
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
const transcript = page.getByTestId("transcript");
// The scripted bot echoes the request, so its reply renders this markdown.
await send(
page,
groupId,
"Summarize the **owl facts** in `notes/owls.md`\n\n## Owl notes\n\n> Owls turn their heads\n\n```\nowls = 3\n```",
);
const firstMessage = transcript
.getByTestId("message-user-bubble")
.filter({ hasText: "Summarize the **owl facts**" });
const markdown = transcript
.locator(".rk-chat-markdown")
.filter({ hasText: "done. i handled: Summarize the owl facts" });
const reply = markdown.locator("p").filter({ hasText: "done. i handled" });
await expect(reply).toBeVisible({ timeout: 60_000 });
await send(page, groupId, "delegate to the bot named Researcher: collect three owl facts");
const task = transcript.getByTestId("delegated-task-card");
await expect(task).toHaveAttribute("data-status", "done", { timeout: 60_000 });
await expect(
transcript.getByText("closing the task: collect three owl facts.").first(),
).toBeVisible({ timeout: 60_000 });
await send(page, groupId, "ask me which city to use");
await expect.poll(() => runStatus(page, groupId), { timeout: 60_000 }).toBe("waiting_input");
const question = transcript
.locator(".rk-chat-markdown p")
.filter({ hasText: /^Which city should I use\?$/ });
// threads/get can observe waiting_input before the realtime feed paints the ask card.
if ((await question.count()) === 0) {
await page.reload({ waitUntil: "domcontentloaded" });
await waitForChatComposer(page);
}
await expect(question).toBeVisible({ timeout: 15_000 });
const lastMessage = transcript
.getByTestId("message-user-bubble")
.filter({ hasText: "ask me which city to use" });
await task.locator("summary").click();
const brief = task.locator(".rk-chat-markdown p");
await expect(brief).toHaveText("collect three owl facts");
await inEachTheme(page, async (theme) => {
await firstMessage.scrollIntoViewIfNeeded();
await captureScreenshot(page, testInfo, `chat-contrast-${theme}-reply`);
await expectReadable(firstMessage, `${theme}: the user's own message`);
await expectReadable(reply, `${theme}: a bot reply`);
await expectReadable(reply.locator("strong"), `${theme}: bold text in a bot reply`);
await expectReadable(reply.locator("code"), `${theme}: inline code in a bot reply`);
await expectReadable(markdown.locator("h2"), `${theme}: a heading in a bot reply`);
await expectReadable(markdown.locator("blockquote p"), `${theme}: a quote in a bot reply`);
await expectReadable(markdown.locator("pre code"), `${theme}: a code block in a bot reply`);
await brief.scrollIntoViewIfNeeded();
await captureScreenshot(page, testInfo, `chat-contrast-${theme}-task`);
await expectReadable(brief, `${theme}: the delegated task brief`);
await question.scrollIntoViewIfNeeded();
await captureScreenshot(page, testInfo, `chat-contrast-${theme}-ask`);
await expectReadable(question, `${theme}: the ask card question`);
await expectReadable(lastMessage, `${theme}: the latest user message`);
});
});
test("shell surfaces stay readable in light and dark themes", async ({ page }, testInfo) => {
await page.emulateMedia({ colorScheme: "light" });
await signup(page, `shell-contrast-${Date.now()}@rakazo.test`, "password12", "Contrast QA");
await completeOnboarding(page);
await createNamedBot(page, "Researcher");
const composer = page.getByRole("combobox", { name: "Message" });
await composer.fill("Draft a note about owls");
const sidebar = page.getByTestId("bots-sidebar");
const sidePanel = page.getByTestId("side-panel");
await expect(sidebar.locator("[data-main-conversation]")).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Researcher, 1:1 conversation" })).toBeVisible();
await expect(page.getByTestId("trading-mode-status")).toBeVisible();
await inEachTheme(page, async (theme) => {
await captureScreenshot(page, testInfo, `shell-contrast-${theme}`);
await expectReadable(composer, `${theme}: the composer draft`);
await expectReadable(page.getByTestId("composer-bar"), `${theme}: the composer`, {
inside: true,
});
// The header carries the logo, which WCAG exempts; its create button is checked on its own.
await expectReadable(sidebar, `${theme}: the sidebar`, { inside: true, skip: ".app-drag" });
await expectReadable(page.getByTestId("create-menu-trigger"), `${theme}: the create button`);
});
await openNewBot(page);
await page.getByTestId("create-bot-form").locator("label:has-text('Name') input").fill("Owl");
await inEachTheme(page, async (theme) => {
await captureScreenshot(page, testInfo, `shell-contrast-${theme}-new-bot`);
await expectReadable(sidePanel, `${theme}: the new bot form`, { inside: true });
});
await page.getByRole("button", { name: "Cancel new bot" }).click();
await page.getByTestId("bot-settings-trigger").click();
const advanced = page.getByTestId("bot-settings-advanced");
await advanced.locator("summary").click();
await expect(advanced.getByText("Open work")).toBeVisible();
await inEachTheme(page, async (theme) => {
await captureScreenshot(page, testInfo, `shell-contrast-${theme}-settings`);
await expectReadable(sidePanel, `${theme}: the bot settings form`, { inside: true });
});
await sidebar.getByRole("button", { name: "Heute", exact: true }).click();
const heute = page.getByTestId("heute-brief");
await expect(heute.getByTestId("heute-mission-control-link")).toBeVisible();
await inEachTheme(page, async (theme) => {
await captureScreenshot(page, testInfo, `shell-contrast-${theme}-heute`);
await expectReadable(heute, `${theme}: the Heute brief`, { inside: true });
});
});

View File

@ -10,16 +10,16 @@ const TRUST_ITEMS = [
export const DataTrustPanel = memo(function DataTrustPanel() {
return (
<div className="hidden xl:flex flex-col items-start gap-1.5 rounded-xl border border-border/40 bg-muted/30 px-3 py-2.5">
<div className="flex items-center gap-1.5 text-xs font-medium text-sidebar-foreground">
<CheckCircle2 className="size-3.5 text-success" />
<div className="flex items-center gap-1.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<CheckCircle2 className="size-3.5" />
<span>Datenschutz aktiv</span>
</div>
<div className="flex flex-col gap-1">
{TRUST_ITEMS.map(({ icon: Icon, label, desc }) => (
<div key={label} className="flex items-center gap-1.5 rk-sidebar-muted">
<Icon className="size-3" />
<span className="text-[11px] leading-tight">{label}</span>
<span className="text-[10px] leading-tight">· {desc}</span>
<div key={label} className="flex items-center gap-1.5">
<Icon className="size-3 text-muted-foreground/60" />
<span className="text-[11px] leading-tight text-muted-foreground">{label}</span>
<span className="text-[10px] leading-tight text-muted-foreground/50">· {desc}</span>
</div>
))}
</div>

View File

@ -45,7 +45,7 @@ export function TradingModeIndicator({
<TradingModeLabel label={presentation.label} />
</span>
{presentation.subtitle ? (
<span className="block truncate text-ui-body-sm rk-sidebar-muted">
<span className="block truncate text-ui-body-sm text-muted-foreground/70">
<TradingModeSubtitleText subtitle={presentation.subtitle} />
</span>
) : null}

View File

@ -175,7 +175,7 @@ export function OoStage({
{(hasMessages || phase !== "idle") && (
<div className={`flex w-full justify-center ${hasMessages ? "mb-1" : "mb-3"}`}>
<div
className="inline-flex items-center gap-2 rounded-full border border-border bg-foreground/5 px-2.5 py-0.5 text-ui-label font-semibold uppercase tracking-wider text-muted-foreground backdrop-blur-sm"
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-0.5 text-ui-label font-semibold uppercase tracking-wider text-muted-foreground backdrop-blur-sm"
style={
reducedMotion
? undefined
@ -280,7 +280,7 @@ function StageEnsemble({
return (
<li key={member.botId} className="flex flex-col items-center gap-1" title={member.name}>
<div
className={`relative flex size-14 items-center justify-center rounded-full border border-border bg-muted/40 backdrop-blur-md ${
className={`relative flex size-14 items-center justify-center rounded-full border border-white/12 bg-slate-950/40 backdrop-blur-md ${
isWorking && !reducedMotion ? "animate-pulse" : ""
}`}
>
@ -484,7 +484,7 @@ export function OoGroupStage({
className={`relative z-10 mx-auto flex w-full max-w-3xl flex-col items-center px-4 md:px-8 ${hasMessages ? "py-2 md:py-3" : "py-5 md:py-6"}`}
>
<div className="mb-3 w-full min-w-0 text-center">
<div className="inline-flex items-center gap-2 rounded-full border border-border bg-foreground/5 px-2.5 py-0.5 text-ui-label font-semibold uppercase tracking-wider text-muted-foreground backdrop-blur-sm">
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-0.5 text-ui-label font-semibold uppercase tracking-wider text-muted-foreground backdrop-blur-sm">
<span
className={`size-1.5 rounded-full ${
running ? "bg-violet-400" : "bg-emerald-400"

View File

@ -55,7 +55,7 @@ export function StageHero({
) : null}
<div
className={`relative flex ${orbSize} items-center justify-center rounded-full border border-border bg-muted/45 shadow-[0_16px_48px_rgba(0,0,0,0.45),inset_0_1px_0_rgba(255,255,255,0.12)] backdrop-blur-xl`}
className={`relative flex ${orbSize} items-center justify-center rounded-full border border-white/14 bg-slate-950/45 shadow-[0_16px_48px_rgba(0,0,0,0.45),inset_0_1px_0_rgba(255,255,255,0.12)] backdrop-blur-xl`}
style={
reducedMotion
? undefined

View File

@ -35,7 +35,7 @@ export function TextStageCanvas({
return (
<div aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute inset-0 bg-radial-[ellipse_100%_70%_at_50%_0%] from-card/55 to-transparent to-68%" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_100%_70%_at_50%_0%,rgba(12,16,24,0.55)_0%,transparent_68%)]" />
<div
className={`absolute left-1/2 top-0 h-[min(48vh,320px)] w-[min(72vw,480px)] -translate-x-1/2 rounded-full blur-[72px] ${reducedMotion ? "" : "voice-bloom-emerald"}`}
style={{

View File

@ -28,15 +28,15 @@ export function VoiceAwarenessStrip({
<div
role="status"
data-testid="voice-awareness"
className="mx-auto flex max-w-2xl items-start gap-3 rounded-[20px] border border-border bg-muted/40 px-4 py-3 backdrop-blur-md"
className="mx-auto flex max-w-2xl items-start gap-3 rounded-[20px] border border-white/10 bg-white/[0.05] px-4 py-3 backdrop-blur-md"
>
<BotAvatar color={botColor} identity={botId} size={32} className="shrink-0" />
<div className="min-w-0 flex-1 text-start">
<div className="flex items-center gap-2 text-ui-label font-semibold uppercase tracking-wide text-muted-foreground">
<div className="flex items-center gap-2 text-ui-label font-semibold uppercase tracking-wide text-zinc-300">
<Icon strokeWidth={1.8} className="size-icon-sm shrink-0 opacity-80" />
<span>{awarenessKindLabel(event.kind)}</span>
</div>
<p dir="auto" className="mt-1 text-ui-body leading-relaxed text-foreground">
<p dir="auto" className="mt-1 text-ui-body leading-relaxed text-zinc-100">
{label}
</p>
</div>

View File

@ -178,7 +178,7 @@ export function HeuteBrief({
href={MISSION_CONTROL_URL}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-ui-label text-muted-foreground transition-colors hover:text-foreground"
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-ui-label text-muted-foreground/70 transition-colors hover:text-muted-foreground"
data-testid="heute-mission-control-link"
>
<Trans>Mission Control</Trans>
@ -235,7 +235,7 @@ function EmptyDay({ onStartChat, hasBots }: { onStartChat: () => void; hasBots:
function ActionableChip({ label }: { label: string }) {
return (
<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">
<span className="shrink-0 rounded-full border border-warning/30 bg-warning/10 px-2 py-0.5 text-ui-label font-medium text-warning">
{label}
</span>
);

View File

@ -106,7 +106,7 @@ export function ScratchpadSection({ botId }: { botId: string }) {
<Trans>Open work</Trans>
</div>
{items.length === 0 ? (
<div className="py-1 text-ui-body text-muted-foreground">
<div className="py-1 text-ui-body text-muted-foreground/80">
<Trans>None yet</Trans>
</div>
) : (

View File

@ -3354,7 +3354,7 @@ export function ShellPage() {
<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"
className="relative flex h-full min-w-0 overflow-hidden bg-black bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-zinc-900 via-black to-black text-foreground/90 p-0 md:p-3 gap-0 md:gap-3"
onTouchStartCapture={(event) => {
if (
mobileSidebarOpen ||
@ -3428,7 +3428,7 @@ export function ShellPage() {
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 ${
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-white/10 md:bg-[#0A0A0C] 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
@ -3456,7 +3456,7 @@ export function ShellPage() {
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"
: "text-muted-foreground/70 hover:text-foreground/75"
}`}
>
<Bell
@ -3469,7 +3469,7 @@ export function ShellPage() {
<Popover open={createMenuOpen} onOpenChange={setCreateMenuOpen}>
<PopoverTrigger
className="app-no-drag text-[21px] rk-sidebar-muted hover:text-foreground/75"
className="app-no-drag text-[21px] text-muted-foreground/70 hover:text-foreground/75"
title={t`Create`}
data-testid="create-menu-trigger"
>
@ -3565,9 +3565,13 @@ export function ShellPage() {
setActiveNavPillar("heute");
setMobileSidebarOpen(false);
}}
className={rosterRowClass(activeNavPillar === "heute")}
className={`flex w-full items-center gap-3 rounded-2xl px-3 py-3 text-start transition-all duration-150 cursor-pointer min-h-[52px] ${
activeNavPillar === "heute"
? "bg-white/[0.09] shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)] border border-emerald-500/30"
: "hover:bg-white/[0.04]"
}`}
>
<div className="relative grid size-control-lg shrink-0 place-items-center rounded-full bg-muted text-muted-foreground">
<div className="relative grid size-control-lg shrink-0 place-items-center rounded-full bg-emerald-500/10 text-emerald-400">
<Calendar strokeWidth={2.2} className="size-icon-md" />
{heuteActionCount > 0 ? (
<span
@ -3592,7 +3596,7 @@ export function ShellPage() {
type="button"
aria-label={t`Neue Unterhaltung`}
data-testid="conversation-picker-trigger"
className="rk-sidebar-muted hover:text-foreground/90 transition-colors"
className="text-muted-foreground/60 hover:text-foreground/90 transition-colors"
>
<Plus strokeWidth={2.5} className="size-icon-md" />
</PopoverTrigger>
@ -3633,7 +3637,7 @@ export function ShellPage() {
</div>
) : null}
{conversationSidebarGroups.length === 0 && !mainConversationEntry ? (
<div className="p-4 text-center text-ui-label rk-sidebar-muted">
<div className="p-4 text-center text-ui-label text-muted-foreground">
<p>{t`Noch keine Unterhaltung.`}</p>
<Button
variant="outline"
@ -3657,7 +3661,7 @@ export function ShellPage() {
<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"
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-muted-foreground/60 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");
@ -3821,10 +3825,12 @@ export function ShellPage() {
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"
className={`absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-[#0b0c10] ${
rosterStatusDot(item.chat.status) === "needs-you"
? "bg-warning animate-pulse"
: rosterStatusDot(item.chat.status) === "working"
? "bg-amber-400 animate-pulse"
: "bg-emerald-400"
}`}
/>
</div>
@ -3841,11 +3847,11 @@ export function ShellPage() {
{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">
<span className="shrink-0 rounded-full border border-warning/30 bg-warning/10 px-2 py-0.5 text-ui-label font-medium 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">
<span className="min-w-0 truncate rounded-full border border-emerald-500/25 bg-emerald-500/10 px-2 py-0.5 text-ui-label font-medium text-emerald-400">
{item.chat.title}
</span>
) : null}
@ -3887,7 +3893,7 @@ export function ShellPage() {
[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"
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 text-muted-foreground hover:bg-sidebar-accent hover:text-foreground transition-colors"
>
{expandedTeamGroups[group.key]
? "Weniger anzeigen"
@ -3899,10 +3905,10 @@ export function ShellPage() {
})}
<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 className="flex items-center justify-between pt-4 pb-1 mt-3 border-t border-white/5 px-2.5">
<div>
<div className="rk-shell-section-label">{t`Projekte`}</div>
<p className="mt-0.5 px-0.5 text-ui-caption rk-sidebar-muted">
<p className="mt-0.5 px-0.5 text-ui-caption text-muted-foreground/70">
{t`Threads by topic · folders`}
</p>
</div>
@ -3912,14 +3918,14 @@ export function ShellPage() {
aria-label={t`Create a project`}
data-testid="create-project-trigger"
onClick={() => openNewSpaceDialog("project")}
className="rk-sidebar-muted hover:text-foreground/90 transition-colors"
className="text-muted-foreground/60 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">
<div className="p-4 text-center text-ui-label text-muted-foreground">
<p>{t`No projects yet.`}</p>
<Button
variant="outline"
@ -3957,7 +3963,7 @@ export function ShellPage() {
</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"
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-muted-foreground/60 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, "/app");
@ -4086,11 +4092,15 @@ export function ShellPage() {
position: { x: event.clientX, y: event.clientY },
});
}}
className={rosterRowClass(
item.kind === "bot"
? !inGroup && active?.id === item.chat.id
: inGroup && activeGroup?.id === item.chat.id,
)}
className={`flex w-full items-center gap-3 rounded-2xl px-3 py-3 mb-[2px] text-start transition-all duration-150 cursor-pointer min-h-[52px] ${
item.kind === "bot" && !inGroup && active?.id === item.chat.id
? "bg-white/[0.09] shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)] border border-emerald-500/30"
: item.kind === "group" &&
inGroup &&
activeGroup?.id === item.chat.id
? "bg-white/[0.09] shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)] border border-emerald-500/30"
: "hover:bg-white/[0.05] border border-transparent"
}`}
>
{item.kind === "bot" ? (
<BotAvatar
@ -4107,30 +4117,34 @@ export function ShellPage() {
<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,
)}`}
className={`shrink-0 max-w-[170px] truncate text-ui-body tracking-tight ${
item.chat.unread
? "font-bold text-white"
: "font-semibold text-white/95"
}`}
>
{item.chat.name}
</span>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<span className={rosterTimeClass}>
<span className="text-ui-body-sm text-zinc-400 tabular-nums font-medium">
{formatRosterTime(item.chat.updatedAt)}
</span>
{item.chat.unread ? (
<span
aria-hidden="true"
className={rosterUnreadDotClass}
className="inline-block h-2 w-2 rounded-full bg-emerald-400"
/>
) : null}
</div>
</div>
<div
dir="auto"
className={`mt-0.5 truncate text-ui-body leading-snug ${rosterPreviewClass(
item.chat.unread,
)}`}
className={`mt-0.5 truncate text-ui-body leading-snug ${
item.chat.unread
? "font-medium text-white"
: "text-zinc-300"
}`}
>
{cleanRosterPreview(item.chat.preview)}
</div>
@ -4153,7 +4167,7 @@ export function ShellPage() {
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"
className="flex w-full items-center justify-between rounded-lg px-2.5 py-2 text-ui-body text-muted-foreground hover:bg-sidebar-accent"
>
<span>
<Trans>Archived</Trans>
@ -4342,7 +4356,7 @@ export function ShellPage() {
<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)]"
className="flex min-w-0 flex-1 flex-col bg-background md:rounded-2xl md:border md:border-white/10 md:bg-[#0A0A0C] 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">
@ -4704,7 +4718,7 @@ export function ShellPage() {
? 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 ${
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-white/10 md:bg-[#0A0A0C] md:shadow-2xl md:relative ${
panel &&
(
active ||
@ -4808,8 +4822,8 @@ export function ShellPage() {
/>
{isDesktop ? (
devServerRunning ? (
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-ui-label font-medium text-foreground">
<span className="size-1.5 rounded-full bg-success" />
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-ui-label font-medium text-emerald-400">
<span className="size-1.5 rounded-full bg-emerald-400" />
<Trans>Dev</Trans>
</span>
) : (
@ -4984,11 +4998,11 @@ export function ShellPage() {
<div className="fixed inset-0 z-50 flex items-center justify-center">
<button
type="button"
className="absolute inset-0 bg-overlay"
className="absolute inset-0 bg-black/60"
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="relative z-10 max-h-[90vh] w-[480px] overflow-y-auto rounded-2xl border border-white/10 bg-[#0A0A0C] p-6 shadow-2xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">
<Trans>Create new Bot</Trans>
@ -6093,10 +6107,10 @@ const Transcript = memo(function Transcript({
<div className="relative mb-5 flex items-center justify-center p-3">
<EvooMascot size={76} pose="greeting" interactive trackPointer="hover" />
</div>
<h2 className="text-3xl font-bold tracking-tight text-foreground mb-2">
{t`Guten Tag.`} evOo {t`ist bereit.`}
<h2 className="text-3xl font-bold tracking-tight text-white mb-2">
{t`Guten Tag.`} <span className="text-emerald-400">evOo</span> {t`ist bereit.`}
</h2>
<p className="max-w-md text-ui-body leading-relaxed text-foreground/85 mb-8 font-normal">
<p className="max-w-md text-ui-body leading-relaxed text-zinc-300 mb-8 font-normal">
{activeName
? t`Frag ${activeName} nach Marktchancen, autonomen Tasks oder Architektur-Reviews.`
: t`Wähle einen autonomen Agenten aus deinem Team oder starte direkt einen Prompt.`}
@ -6107,13 +6121,13 @@ const Transcript = memo(function Transcript({
onClick={() => {
onPromptSelect?.(t`⚡ Führe einen autonomen Portfolio- und Risiko-Check durch`);
}}
className="apple-glass-card flex flex-col items-start p-4 rounded-2xl text-start transition-all hover:scale-[1.02] cursor-pointer"
className="apple-glass-card group flex flex-col items-start p-4 rounded-2xl text-start transition-all hover:scale-[1.02] hover:border-emerald-500/40 cursor-pointer"
>
<span className="text-xl mb-2">⚡</span>
<span className="text-[14.5px] font-semibold text-foreground">
<span className="text-[14.5px] font-semibold text-white group-hover:text-emerald-400 transition-colors">
{t`Portfolio-Check`}
</span>
<span className="text-ui-body-sm text-muted-foreground mt-1 leading-snug">
<span className="text-ui-body-sm text-zinc-400 mt-1 leading-snug">
{t`Echtzeit-Risiko & Drawdown-Analyse`}
</span>
</button>
@ -6122,13 +6136,13 @@ const Transcript = memo(function Transcript({
onClick={() => {
onPromptSelect?.(t`📈 Scanne den Markt nach aktuellen Alpha-Signalen`);
}}
className="apple-glass-card flex flex-col items-start p-4 rounded-2xl text-start transition-all hover:scale-[1.02] cursor-pointer"
className="apple-glass-card group flex flex-col items-start p-4 rounded-2xl text-start transition-all hover:scale-[1.02] hover:border-sky-500/40 cursor-pointer"
>
<span className="text-xl mb-2">📈</span>
<span className="text-[14.5px] font-semibold text-foreground">
<span className="text-[14.5px] font-semibold text-white group-hover:text-sky-400 transition-colors">
{t`Alpha-Signale`}
</span>
<span className="text-ui-body-sm text-muted-foreground mt-1 leading-snug">
<span className="text-ui-body-sm text-zinc-400 mt-1 leading-snug">
{t`Volatilität & Orderbuch-Liquidität`}
</span>
</button>
@ -6137,13 +6151,13 @@ const Transcript = memo(function Transcript({
onClick={() => {
onPromptSelect?.(t`💎 Führe ein Steve Jobs UX & Architektur-Audit durch`);
}}
className="apple-glass-card flex flex-col items-start p-4 rounded-2xl text-start transition-all hover:scale-[1.02] cursor-pointer"
className="apple-glass-card group flex flex-col items-start p-4 rounded-2xl text-start transition-all hover:scale-[1.02] hover:border-purple-500/40 cursor-pointer"
>
<span className="text-xl mb-2">💎</span>
<span className="text-[14.5px] font-semibold text-foreground">
<span className="text-[14.5px] font-semibold text-white group-hover:text-purple-400 transition-colors">
{t`Steve Jobs Audit`}
</span>
<span className="text-ui-body-sm text-muted-foreground mt-1 leading-snug">
<span className="text-ui-body-sm text-zinc-400 mt-1 leading-snug">
{t`Schlichtheit, Eleganz & Detailtiefe`}
</span>
</button>
@ -6719,7 +6733,7 @@ const Composer = memo(function Composer({
{/* Quick Action Prompt Chips */}
{showComposerPlaceholder ? (
<div className="mb-3 flex items-center gap-2 overflow-x-auto no-scrollbar py-1">
<span className="flex items-center gap-1.5 text-ui-label font-semibold uppercase tracking-wider text-muted-foreground shrink-0 px-2.5 py-1 rounded-full bg-muted/40 border border-border me-1 shadow-xs">
<span className="flex items-center gap-1.5 text-ui-label font-semibold uppercase tracking-wider text-emerald-400 shrink-0 px-2.5 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 me-1 shadow-xs">
<Sparkles className="text-amber-400 size-icon-sm" />
Quick
</span>
@ -6731,7 +6745,7 @@ const Composer = memo(function Composer({
updateDraft(promptText);
focusComposer();
}}
className="shrink-0 rounded-full border border-border bg-muted/40 px-3.5 py-1.5 text-ui-body-sm font-medium text-foreground/85 hover:bg-accent hover:text-foreground transition-all shadow-sm active:scale-95 cursor-pointer"
className="shrink-0 rounded-full border border-white/[0.1] bg-white/[0.05] px-3.5 py-1.5 text-ui-body-sm font-medium text-zinc-200 hover:border-emerald-500/40 hover:bg-white/[0.1] hover:text-white transition-all shadow-sm active:scale-95 cursor-pointer"
>
{promptText}
</button>
@ -6914,7 +6928,7 @@ const Composer = memo(function Composer({
) : 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"
className="apple-island relative flex items-center gap-3.5 rounded-[30px] py-3 pe-3.5 ps-4 transition-all duration-200 focus-within:border-emerald-500/40 focus-within:shadow-[0_20px_50px_-10px_rgba(0,0,0,0.85),inset_0_1px_0_rgba(255,255,255,0.22),0_0_0_1px_rgba(16,185,129,0.35)] shadow-[0_16px_40px_-8px_rgba(0,0,0,0.7),inset_0_1px_0_rgba(255,255,255,0.14)]"
>
<input
ref={fileInputRef}
@ -6930,7 +6944,7 @@ const Composer = memo(function Composer({
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"
className="tactile-press size-10 shrink-0 rounded-full border border-white/10 bg-white/[0.06] text-foreground/80 transition-all hover:bg-white/[0.12] hover:text-white flex items-center justify-center cursor-pointer"
>
<Plus strokeWidth={2.2} className="size-icon-lg" />
</Button>
@ -6938,9 +6952,9 @@ const Composer = memo(function Composer({
{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"
className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-white/10 bg-white/[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" />
<Box strokeWidth={1.8} className="shrink-0 text-blue-400 size-icon-md" />
<span dir="auto" className="truncate">
{selectedSkill.name}
</span>
@ -6959,7 +6973,7 @@ const Composer = memo(function Composer({
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"
className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.08] px-3 py-1 text-ui-body font-medium text-foreground backdrop-blur-md"
>
<MentionChipIcon mention={mention} />
<span dir="auto" className="truncate">
@ -7039,7 +7053,7 @@ const Composer = memo(function Composer({
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"
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 selection:bg-emerald-500/30"
/>
</div>
{onVoice ? (
@ -7072,7 +7086,7 @@ const Composer = memo(function Composer({
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"
className="tactile-press size-10 rounded-full bg-emerald-400 text-slate-950 hover:bg-emerald-300 shadow-[0_0_16px_rgba(16,185,129,0.35)] transition-transform active:scale-90 flex items-center justify-center cursor-pointer"
>
<ArrowUp strokeWidth={2.6} className="size-icon-lg" />
</Button>
@ -7082,7 +7096,7 @@ const Composer = memo(function Composer({
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"
className="tactile-press size-10 rounded-full border border-white/15 bg-white/[0.08] text-foreground/90 shadow-sm transition-colors hover:bg-white/[0.15] hover:text-white flex items-center justify-center cursor-pointer"
>
<Square strokeWidth={0} fill="currentColor" className="size-icon-sm" />
</Button>
@ -7093,7 +7107,7 @@ const Composer = memo(function Composer({
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"
className="tactile-press size-10 shrink-0 rounded-full bg-emerald-400 text-slate-950 hover:bg-emerald-300 shadow-[0_0_16px_rgba(16,185,129,0.35)] transition-all active:scale-90 disabled:bg-white/[0.08] disabled:text-white/20 disabled:shadow-none flex items-center justify-center cursor-pointer"
>
<ArrowUp strokeWidth={2.6} className="size-icon-lg" />
</Button>
@ -7490,22 +7504,19 @@ const MessageView = memo(function MessageView({
{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">
<div className="relative flex items-center justify-center size-7 rounded-xl bg-white/[0.08] border border-white/[0.12] 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"
>
<span className="text-[14.5px] font-semibold text-white 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">
<span className="rounded-full bg-emerald-500/15 border border-emerald-500/25 px-2 py-0.5 text-ui-label font-medium text-emerald-400">
Oo
</span>
</div>
</div>
<span className="text-ui-label font-mono text-muted-foreground tabular-nums">
<span className="text-ui-label font-mono text-zinc-400 tabular-nums">
{new Date(message.createdAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
@ -7861,8 +7872,8 @@ const MessageView = memo(function MessageView({
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"
? "max-w-full whitespace-pre-wrap wrap-anywhere rounded-2xl border border-white/10 bg-white/[0.06] px-3.5 py-2 text-ui-body-sm leading-relaxed text-foreground/95"
: "chat-bubble-user max-w-full whitespace-pre-wrap wrap-anywhere rounded-[24px] px-6 py-4 text-[17px] leading-[1.62] text-[#FAFAFA] shadow-md"
}
dir="auto"
>
@ -7884,7 +7895,7 @@ const MessageView = memo(function MessageView({
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="mt-3 pt-2.5 flex items-center justify-between border-t border-white/[0.08] text-ui-label text-muted-foreground/75">
<div className="flex items-center gap-3">
<button
type="button"
@ -7893,7 +7904,7 @@ const MessageView = memo(function MessageView({
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"
className="inline-flex items-center gap-1.5 hover:text-white transition-colors cursor-pointer text-ui-body-sm font-medium"
title={t`Antwort kopieren`}
>
<Copy size={13.5} />
@ -7904,14 +7915,14 @@ const MessageView = memo(function MessageView({
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"
className="inline-flex items-center gap-1.5 hover:text-white transition-colors cursor-pointer text-ui-body-sm font-medium"
>
<Volume2 size={13.5} />
<span>{speaking ? t`Stop` : t`Vorlesen`}</span>
</button>
) : null}
</div>
<span className="tabular-nums font-mono text-ui-label text-muted-foreground">
<span className="tabular-nums font-mono text-ui-label text-zinc-400">
{new Date(message.createdAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
@ -7929,7 +7940,7 @@ const MessageView = memo(function MessageView({
{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="font-semibold text-white">{line.k}</span>
<span className="text-muted-foreground">→</span>
<span>{line.v}</span>
</div>

View File

@ -441,7 +441,7 @@ export function BotSettings({
}}
/>
</div>
<label htmlFor={`${ids}-name`} className={fieldLabelClass}>
<label htmlFor={`${ids}-name`} className="mt-4 block text-ui-body text-muted-foreground/80">
<Trans>Name</Trans>
<Input
id={`${ids}-name`}
@ -484,7 +484,10 @@ export function BotSettings({
>
<Trans>Notifications</Trans>
</div>
<div id={`${ids}-notify-finish-desc`} className="text-ui-body-sm text-muted-foreground">
<div
id={`${ids}-notify-finish-desc`}
className="text-ui-body-sm text-muted-foreground/70"
>
<Trans>Get notified when this Bot finishes or needs input</Trans>
</div>
</div>

View File

@ -428,7 +428,7 @@ export function ChartBlockView({
variant="outline"
size="xs"
onClick={() => setExpanded(true)}
className="absolute end-3 top-3 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 rounded-full"
className="absolute end-3 top-3 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 rounded-full border-white/15 bg-white/10 text-white backdrop-blur-md"
>
<Trans>Expand</Trans>
</Button>

View File

@ -1,20 +1,20 @@
/** One look for sidebar conversation rows: the main conversation entry and the bot roster. */
export function rosterRowClass(selected: boolean): string {
return `flex w-full items-center gap-3 rounded-2xl border px-3 py-3 mb-[2px] text-start transition-all duration-150 cursor-pointer min-h-[52px] ${
return `flex w-full items-center gap-3 rounded-2xl px-3 py-3 mb-[2px] text-start transition-all duration-150 cursor-pointer min-h-[52px] ${
selected
? "border-sidebar-border bg-sidebar-accent shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)]"
: "border-transparent hover:bg-sidebar-accent/50"
? "bg-white/[0.09] shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)] border border-emerald-500/30"
: "hover:bg-white/[0.05] border border-transparent"
}`;
}
export function rosterNameClass(unread: boolean): string {
return unread ? "font-bold text-sidebar-foreground" : "font-semibold text-sidebar-foreground";
return unread ? "font-bold text-white" : "font-semibold text-white/95";
}
export const rosterTimeClass = "text-ui-body-sm rk-sidebar-muted tabular-nums font-medium";
export const rosterTimeClass = "text-ui-body-sm text-zinc-400 tabular-nums font-medium";
export const rosterUnreadDotClass = "inline-block h-2 w-2 rounded-full bg-sidebar-foreground";
export const rosterUnreadDotClass = "inline-block h-2 w-2 rounded-full bg-emerald-400";
export function rosterPreviewClass(unread: boolean): string {
return unread ? "font-medium text-sidebar-foreground" : "text-sidebar-foreground/85";
return unread ? "font-medium text-white" : "text-zinc-300";
}

View File

@ -11,7 +11,13 @@ body,
#root {
height: 100%;
margin: 0;
background: var(--background);
background: #07090e;
background-image: radial-gradient(
circle at 50% 0%,
rgba(16, 185, 129, 0.07) 0%,
rgba(14, 165, 233, 0.04) 40%,
transparent 80%
);
color: var(--foreground);
font-family:
"Inter Variable", -apple-system, BlinkMacSystemFont, "SF Pro Display", "Geist Variable",
@ -35,8 +41,8 @@ body,
.apple-island {
backdrop-filter: blur(36px) saturate(210%);
-webkit-backdrop-filter: blur(36px) saturate(210%);
background: color-mix(in srgb, var(--card) 88%, transparent);
border: 1px solid color-mix(in srgb, var(--foreground) 13%, transparent);
background: rgba(12, 16, 24, 0.88);
border: 1px solid rgba(255, 255, 255, 0.13);
box-shadow:
0 18px 44px -8px rgba(0, 0, 0, 0.75),
inset 0 1px 0 rgba(255, 255, 255, 0.16);
@ -45,24 +51,24 @@ body,
.apple-glass-card {
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
background: color-mix(in srgb, var(--card) 68%, transparent);
border: 1px solid var(--border);
background: rgba(16, 21, 30, 0.68);
border: 1px solid rgba(255, 255, 255, 0.09);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.1),
0 6px 24px rgba(0, 0, 0, 0.35);
}
.chat-bubble-user {
background: var(--chat-user);
border: 1px solid var(--border);
background: linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(15, 23, 42, 0.98) 100%);
border: 1px solid rgba(255, 255, 255, 0.14);
box-shadow:
0 8px 24px -4px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
}
.chat-bubble-bot {
background: color-mix(in srgb, var(--card) 72%, transparent);
border: 1px solid var(--border);
background: rgba(15, 20, 29, 0.72);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow:
0 10px 32px -4px rgba(0, 0, 0, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
@ -73,7 +79,7 @@ body,
/* Shell layout utilities — token-backed, use instead of one-off pixel classes */
@layer components {
.rk-shell-section-label {
@apply text-ui-label font-bold tracking-wider uppercase text-foreground/65 dark:text-muted-foreground/80;
@apply text-ui-label font-bold tracking-wider uppercase text-muted-foreground/80;
}
.rk-shell-icon-btn {
@ -84,23 +90,16 @@ body,
@apply text-ui-body-sm text-muted-foreground;
}
/* Secondary sidebar text and icons. Muted text measures 4.4:1 on the light sidebar;
a dimmed foreground stays above 4.5:1. */
.rk-sidebar-muted {
@apply text-sidebar-foreground/65;
}
.rk-shell-row-title {
@apply text-ui-body font-medium text-foreground;
}
}
/* Gemini-Grade Accessible Typography (Apple-like with Inter Variable).
Heading, code and quote colors come from the shared @rakazo/chat-ui markdown styles. */
/* Gemini-Grade Accessible Typography (Apple-like with Inter Variable) */
.rk-chat-markdown {
font-size: var(--font-size-reading);
line-height: var(--line-height-reading);
color: var(--foreground);
color: #f8fafc;
letter-spacing: -0.011em;
font-weight: 400;
}
@ -111,6 +110,8 @@ body,
}
.rk-chat-markdown pre {
background: rgba(8, 11, 16, 0.96);
border: 1px solid rgba(255, 255, 255, 0.11);
border-radius: 16px;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.09),
@ -127,6 +128,9 @@ body,
}
.rk-chat-markdown code:not(pre code) {
background: rgba(255, 255, 255, 0.09);
border: 1px solid rgba(255, 255, 255, 0.13);
color: var(--foreground);
font-weight: 500;
border-radius: 6px;
padding: 0.15em 0.4em;
@ -139,6 +143,7 @@ body,
font-weight: 700;
line-height: 1.3;
letter-spacing: -0.025em;
color: #ffffff;
margin: 1.6em 0 0.6em;
}
@ -147,6 +152,7 @@ body,
font-weight: 600;
line-height: 1.35;
letter-spacing: -0.02em;
color: #ffffff;
margin: 1.4em 0 0.5em;
}
@ -155,6 +161,7 @@ body,
font-weight: 600;
line-height: 1.4;
letter-spacing: -0.015em;
color: #ffffff;
margin: 1.2em 0 0.4em;
}
@ -170,13 +177,17 @@ body,
}
.rk-chat-markdown strong {
color: #ffffff;
font-weight: 600;
}
.rk-chat-markdown blockquote {
border-left: 3px solid #10b981;
padding-left: 1.2em;
margin: 1.3em 0;
color: #cbd5e1;
font-style: normal;
background: rgba(16, 185, 129, 0.05);
border-radius: 0 8px 8px 0;
padding-top: 0.4em;
padding-bottom: 0.4em;
@ -224,11 +235,11 @@ body,
background: transparent;
}
.rk-scroll::-webkit-scrollbar-thumb {
background: var(--scrollbar);
background: rgba(255, 255, 255, 0.14);
border-radius: 3px;
}
.rk-scroll::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-hover);
background: rgba(255, 255, 255, 0.28);
}
.app-drag {

View File

@ -38,27 +38,17 @@ describe("server image publish workflow", () => {
expect(publish.needs).toBe("build");
expect(workflowText).toContain("push-by-digest=true");
expect(workflowText).toContain("docker buildx imagetools create");
expect(workflowText).toContain(`if [[ "\${#sources[@]}" -ne 1 ]]`);
expect(workflowText).toContain('if [[ "${#sources[@]}" -ne 1 ]]');
expect(workflowText).toContain("for want in linux/amd64; do");
expect(workflowText).toContain("actions/attest-build-provenance@");
expect(workflowText).not.toContain("github.repository_owner_type");
expect(workflowText).toContain(`gh api "repos/\${GITHUB_REPOSITORY}"`);
expect(workflowText).toContain(".owner.type");
expect(workflowText).toContain("if: steps.attestation.outputs.enabled == 'true'");
expect(workflowText).toContain('[[ "$owner_type" == "User" && "$private" == "true" ]]');
});
it("stores digest markers under RUNNER_TEMP so self-hosted runners do not leak stale files", () => {
expect(workflowText).not.toContain("/tmp/digests");
expect(workflowText).toContain("RUNNER_TEMP");
});
it("names every image under the lowercase repository namespace", () => {
expect(workflowText).not.toContain(`ghcr.io/\${{ github.repository }}`);
expect(workflowText).not.toContain("ghcr.io/${{ github.repository }}");
const references =
workflowText.match(/ghcr\.io\/\$\{\{ steps\.ns\.outputs\.repo \}\}\//g) ?? [];
expect(references.length).toBeGreaterThanOrEqual(6);
expect(workflowText).toContain(`repo=\${GITHUB_REPOSITORY,,}`);
expect(workflowText).toContain("repo=${GITHUB_REPOSITORY,,}");
});
it("keeps pull requests read-only and every action pinned to a commit", () => {

View File

@ -157,6 +157,7 @@
.rk-chat-markdown blockquote {
border-left: 3px solid var(--border);
padding-left: 0.85em;
color: var(--muted-foreground);
}
.rk-chat-markdown table {

View File

@ -49,7 +49,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-ui-body-sm font-medium whitespace-nowrap text-muted-foreground transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-icon-md",
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-ui-body-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-icon-md",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",