You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/10/17 10:10:25 UTC

(maka) branch rust-preview updated: feat(desktop): Maka desktop on gpui-shell (#6138)

This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git


The following commit(s) were added to refs/heads/rust-preview by this push:
     new 83725ec9f9 feat(desktop): Maka desktop on gpui-shell (#6138)
83725ec9f9 is described below

commit 83725ec9f9cbf8313571d1b854ac56b25540da39
Author: UDY4M <72...@users.noreply.github.com>
AuthorDate: Sat Oct 17 18:10:18 2026 +0800

    feat(desktop): Maka desktop on gpui-shell (#6138)
    
    * chore(gpui): vendor gpui-shell from Kit c5f8eca
    
    Copy the scriptable runtime and its rquickjs facade beside the adapted Base,
    record the imported files in upstream.json, and adapt the runtime to Base's
    link activation and ElementExt bounds. The gpui-component catalog is left
    out because it does not build against the adapted Base.
    
    Generated-by: Claude Code
    
    * feat(desktop): add a gpui-shell desktop host probe
    
    Build the vendored gpui-shell command inside this workspace so desktop
    experiments run against the same Base and Fast sources as the product.
    
    Generated-by: Claude Code
    
    * feat(desktop): bridge gpui-shell scripts to a Maka Host
    
    Register a `maka-host` script module in the desktop probe. It reaches
    the Host named by MAKA_HOST_ENDPOINT (socket path) and MAKA_HOST_EPOCH
    (optional) over an owned session that connects on the first call, and
    exposes query, mutate (start then confirm once, resolving with the
    operation reply), subscribe, next and close.
    
    Calls run on a small tokio runtime owned by the module and answer
    through a oneshot, so GPUI's executor only waits. Host errors reject
    the promise with the Host's message; a method called with the wrong
    function is cancelled and the error names the right one. When a call
    channel is refused the session is dropped, and the next call connects
    again.
    
    Generated-by: Claude Code
    
    * feat(desktop): add a script desktop UI on gpui-shell
    
    products/maka/desktop-shell/app is a gpui-shell application written in
    JavaScript. Every Host call goes through the maka-host module (host.js).
    
    - sessions.js: one agent.catalog subscription with a single next() loop,
      newest first, with a "Needs you" or "Working" marker. When the
      subscription fails it falls back to one agent.list query and opens it
      again after two seconds.
    - transcript.js: observe, watch from the observed fence (or from the
      first open part), the tail page at that fence, older pages at the same
      fence through the before cursor, and history re-reads after the last
      loaded row when a fact changes the transcript. Deltas grow live rows
      until history has the message. This follows plugins/agent/agent-screen.
      Rows are drawn by GPUI's measured list; assistant rows are Markdown.
    - composer.js: a multi-line draft, model choice 0 of settings.models,
      Send, and Stop while a run is open. Enter sends, Shift+Enter inserts a
      newline.
    - main.js: the window, the status line, and an optional dev.js (not
      committed) that turns on the debug log and drives the window.
    
    Limits of the runtime this UI works around or does not meet: the shell
    list always splices its whole range when the item count changes, which
    moves the view to the first row, and it has no scroll handle, so the
    transcript cannot start at or stay at the bottom; older pages load from
    a wheel event near the top, not from a scroll callback; a Textarea has no
    submit-on-Enter switch, so the composer removes the newline Enter
    inserted.
    
    Generated-by: Claude Code
    
    * chore(gpui): backport gpui-shell list and textarea fixes
    
    Bring four open GPUI Kit pull requests (#3547 to #3550) into the vendored
    shell. A script `list` now keeps the reader on the item at the top of the
    view when its item count changes, can follow its end with
    `with_follow_mode("tail")`, and reports its visible range after a frame that
    changed it. `TextareaState.set_submit_on_enter` makes Enter submit without
    inserting a line break. SOURCE.md records them until the next sync replaces
    them with the upstream versions.
    
    Generated-by: Claude Code
    
    * feat(desktop): follow the transcript tail and send on Enter
    
    The transcript opens at the newest row and stays there while a reply
    streams, until the reader scrolls up. Older pages load when the visible
    range reaches the top, however the list was moved, instead of on wheel
    events alone. The composer sends on Enter without inserting a newline, so it
    no longer strips one from the draft.
    
    Generated-by: Claude Code
    
    * feat(desktop): start sessions and answer what the agent asks
    
    The sidebar's New button starts a session in the selected session's
    workspace, and with nothing selected the main pane lists the Host's root
    directories to start one in. The title comes from the first message.
    
    A card above the composer answers the first pending request: service and
    capability approvals (Deny/Allow), permission requests with the command
    and directory they are for (Deny, or allow once, for the turn or for the
    session), and questions (one option or Skip per part, Submit when there
    are several). Forms are left to the terminal app. An answer is sent once
    and never retried; a rejected answer is reported and the pending list is
    read again.
    
    The session list reopens its catalog subscription when a live page says
    sessions sort before its window. After a change the Host reads from the
    first id it last sent, and ids are random, so a new session could be left
    out until restart. Live text that closed without reaching history, such as
    the empty text of a tool-call step, is dropped once history is read past
    it, and the header shows the catalog's title.
    
    Generated-by: Claude Code
    
    * feat(desktop): send with the session's model
    
    A session sends with its own backend from `agent.observe` (the session's,
    else the one the owner remembers), as the TUI picks it, and otherwise with
    the configured default model, falling back to the first one offered. The
    backend names no connection revision, so its choice is looked up in
    `settings.models` at send time. The footer names the model a message will
    use. Sessions on an executor are refused with a message, since the desktop
    cannot start one yet.
    
    Generated-by: Claude Code
    
    * fix(desktop): list every session and recover from failed reads
    
    The Host pages the catalog 32 sessions at a time in id order, and ids are
    random, so the newest sessions can be on any page. The sidebar now reads
    the pages after the subscribed first one at its revision and lists them
    all, newest first; a revision conflict waits for the page the change
    brings. A new session therefore always appears, and the header takes its
    title.
    
    Reopening the catalog subscription no longer reports the closed handle as
    an error. A pending request that cannot be read shows a card with Try
    again, since the pending list may not change while the agent waits. A
    failed directory listing offers List directories again on the start pane.
    
    Generated-by: Claude Code
    
    * feat(desktop): pass resource attachments through maka-host
    
    `mutate` takes an optional fourth argument that maps a method's resource
    slots to text. Each value is sent as an immutable `maka.text.utf8`
    resource, which is how `settings.saveCredential` receives the key: the
    Host takes the secret from the `secret` slot, never from the JSON input.
    The resource type has no Debug formatting, so the text cannot reach a
    log through it. Queries and subscriptions send no attachments.
    
    Generated-by: Claude Code
    
    * feat(desktop): read a text file the user picks
    
    gpui-shell scripts have no file dialog, and a manifest can grant reads
    only under the application's own directories. The `maka-files` module
    adds `openText`, which shows the platform's open panel and reads the one
    file the user chose as UTF-8 text, within a size limit (1 MiB by
    default, 16 MiB at most). Choosing the file is the permission, as with
    a browser's file input; nothing else on disk becomes readable. Settings
    uses it to import an old Maka settings export.
    
    Generated-by: Claude Code
    
    * feat(desktop): add model connections and keys in Settings
    
    A Settings page, opened from the bottom of the sidebar, does what the
    TUI's settings plugins do for models:
    - lists the connections with their provider, enabled model count and
      whether a key is saved;
    - adds a connection: choose one of the Host's providers, then name,
      endpoint (prefilled from the provider's defaults) and API key in a
      masked field. The desktop creates the connection, saves the key as
      the `secret` resource of `settings.saveCredential`, then calls
      `setup.discoverModels` so the provider's models are enabled;
    - sets or replaces the key of a connection, and finds its models again;
    - chooses the default model, which the composer then sends with;
    - imports the API keys of an old Maka settings export (schema version
      1), one connection per key, matched to the provider of the same name
      and keeping the export's endpoint when it differs from the default.
      Rows that cannot be imported (another kind of credential, an unknown
      provider, an ID already in use) are listed with the reason.
    
    A key is never logged. Import rows drop their key once imported, skipped
    or cancelled. A key the provider refuses leaves the connection without
    one, and the row says so; Set key retries. With no model set up, the
    start pane offers to open Settings.
    
    Generated-by: Claude Code
    
    * feat(desktop): choose the folder a new session works in
    
    With no session selected, the start pane listed only the Host's root
    directories and started a session in the root itself. A Host serving a
    fresh root offers the home directory as its one root, so every new
    session worked in the whole home directory.
    
    The start pane now walks into folders through `agent.directories`
    children pages, as the TUI's directory picker does: open a folder, go
    up, show more when a folder has more than 32, and start a session in
    the folder shown.
    
    Generated-by: Claude Code
    
    * fix(desktop): do not report a Host without models as an error
    
    The start pane already says that no model is set up and offers
    Settings, so the status line no longer shows it in red.
    
    Generated-by: Claude Code
    
    * fix(gpui-fast): keep list rows' layout-time element states on the layer
    
    Backports longbridge/gpui-fast#73 until it is merged. A frame that kept
    a list row on the list's scroll layer dropped the element states the
    row's render and layout took, because they lie before the row's
    prepaint records. gpui-kit's TextView keeps a parsed document of more
    than 4 KB that way, so while scrolling inside a tall markdown row the
    row was laid out short for one frame and the rows after it showed
    through. SOURCE.md and SOURCE.zh-CN.md describe the change; upstream.json
    keeps the upstream hashes.
    
    Generated-by: Claude Code
    
    * fix(desktop): send from the start pane by starting a session
    
    With no session selected the composer took text, but Send stayed
    disabled and Enter did nothing. Sending now starts a session in the
    folder the start pane shows (the first root until a folder is opened)
    and sends the message to it. The composer's footer names that folder.
    
    Generated-by: Claude Code
    
    * fix(gpui-kit): keep theme alpha in scripts and select input text visibly
    
    Two fixes found while giving the desktop its own palette. The shell's
    `cx.theme()` wrote every color as `#rrggbb`, so a script that drew a
    border in a translucent token such as `input` or `border` got it
    opaque; translucent colors are now written as `#rrggbbaa`. Base's input
    editor drew an unstyled selection in `accent` at 40%, which cannot be
    seen when a product sets `accent` to an opaque fill (the desktop uses
    it for code blocks); it now uses the palette's `selection` role, at
    most 40% opaque, as text views do. SOURCE.md records both.
    
    Generated-by: Claude Code
    
    * chore(license): cover SVG files with the ASF header
    
    The desktop is about to ship Maka's own icons as SVG files. They are
    text that Maka wrote, so they carry the header in an XML comment, as
    HTML and Markdown files do, instead of an exclusion.
    
    Generated-by: Claude Code
    
    * feat(desktop): give the desktop the Maka look
    
    The desktop now looks like the native Maka client its screenshots were
    signed off on (maka-gpui), redrawn in scripts:
    
    - theme.js holds Maka's light and dark palette, maps it onto gpui-base's
      tokens, follows the system appearance, and builds the shared
      controls; icons/ holds Maka's own icon set and wordmark.
    - The sidebar groups tasks by time or by project, with New task, an age
      or waiting/running mark per task, Show more, and the Host's state and
      Settings at the foot. The content sits on a rounded plate under a
      header that names the task and whether it needs you.
    - The transcript is a 720px reading column: user bubbles, markdown
      replies, folded thinking, grouped tool rows, and a footer per finished
      turn with its outcome, time and Copy. Earlier messages load from a
      row at the top that retries when loading failed.
    - Tool rows read their arguments and results with `agent.source` when
      they need them, because a conversation history page carries tool
      metadata only. A row shows the call's title or the argument its
      contract names (path, command, pattern), marks a result whose kind is
      failed, and opened shows the command and its output.
    - The composer is the rounded dock with a model menu grouped by
      connection, the folder for a new task, and Send or Stop. Interaction
      cards, the start pane and the Settings pages use the same controls.
    
    Generated-by: Claude Code
    
    * feat(desktop): open in Maka's window chrome, with its shortcuts
    
    The window is the native client's: no title bar, the traffic lights in
    the sidebar's top strip, a 1512x885 window no smaller than 960x600, and
    "Maka" as its title.
    
    - The strip holds the sidebar toggle, Back and Forward. With the
      sidebar hidden they move into the plate's header, clear of the
      traffic lights. Settings keep their navigation in the sidebar's place
      even then, and leave these controls out, as the native client does.
    - Back and Forward step through the tasks and the start pane visited.
    - Cmd-N, Cmd-B, Cmd-[, Cmd-], Cmd-, and Cmd-. are bound as the native
      client binds them. Their actions are handled on the app's root, which
      holds the focus whenever nothing inside it does, so they work before
      anything is clicked and after a press on the background.
    - An icon with a tooltip sits in a div, since an svg has no hover
      listeners. Models in Settings shows a chip icon, as in the native
      client.
    
    Generated-by: Claude Code
    
    * feat(desktop): fill in the forms the agent asks for
    
    A form request (maka.interactions.Forms) was left to the terminal app.
    The interaction card now fills it in, with the answers the terminal's
    form editor sends (plugins/agent/form-editor):
    
    - One row per field, as Settings draws them: the label, Required, the
      description and the field's limits in words, and the control: a text
      field for text and numbers (starting from the default), Yes and No for
      a boolean, one button per option for a single or multi select. The
      rows scroll when a form is long.
    - Submit checks what the Host checks (form_validation.rs): required
      fields, length, number ranges and whole numbers, email, URI, date and
      date-time formats, choice counts, and the size of the answer. The
      first field that fails is named on its row and under the form, and
      editing it clears that.
    - An optional field is sent only when it has a value; choosing an
      optional field's choice again clears it. Values are typed (numbers,
      booleans, option values in the options' order).
    - Decline and Cancel request answer without values.
    
    Generated-by: Claude Code
    
    * feat(desktop): remove a connection from Settings
    
    Each editable connection has Remove, which asks first: the Host removes
    the connection with its API keys and, when it was the default, the
    default model; tasks that used it are kept. It sends settings.remove
    with the connection's revision as shown, as the terminal app does, so
    a connection changed meanwhile is refused rather than removed.
    
    Generated-by: Claude Code
    
    * feat(desktop): show a tool's whole output, scrolling
    
    An opened tool row showed the first excerpt of its result as raw JSON,
    cut at 4000 characters, so long output showed only its start.
    
    - A contract's result is read by the paths the TUI's tool details read:
      a failure's message, a process's stdout and stderr (named when both
      have text) or its screen, a file's content, the files or matches
      found. Each is plain text however long.
    - A value of up to 64 KB is read whole, excerpt after excerpt; a longer
      one shows its start and its end with the size between them.
    - When the Host kept only the end of a process's output, the block says
      earlier output was not kept and starts at the first whole line.
    - The block scrolls within 256px. It lays out at most 32K characters,
      its first and last halves, since it is laid out again on every frame
      the transcript scrolls.
    
    Generated-by: Claude Code
    
    * feat(gpui-shell): add svg spin and TextView text_style
    
    Scripts had no way to draw a loading indicator that turns, and no way
    to change how a TextView document is styled beyond the theme.
    
    svg(path).spin(period_ms) turns the icon once every period_ms. The
    angle is computed at paint time from the time since one process-wide
    epoch, not from per-element animation state, so a script render that
    rebuilds the icon does not restart the turn. Each paint requests the
    next animation frame; that frame notifies the view, which materializes
    its retained snapshot again without running the script's render. The
    request is made during paint, so GPUI-fast does not composite a scroll
    container holding a spinner from a cached layer. Reduced motion draws
    the icon upright and requests no frame. svg(...) now returns
    SvgElement, which declares spin; elsewhere spin is recorded, logged as
    not honoured and ignored, as tooltip is.
    
    TextView.text_style({...}) replaces parts of the style the shell builds
    from the theme: headings (default and levels 1 to 6), paragraph_gap,
    colors, code font, code_block, table_head and table_cell. Keys left out
    keep the theme's value; nested objects take the names and values of
    the element style methods. Method arguments cross as primitives, so
    the prelude sends one path/value pair per leaf, and unknown keys, null
    and wrong types throw at the call site.
    
    Tests cover the angle math, no script render across spinner frames,
    continuity across a rebuild, reduced motion, painting on every frame
    under scroll layers, key validation, folding, rendered document
    height, and the declarations against the runtime's key lists.
    
    Generated-by: Claude Code
    
    * feat(desktop): turn the running glyph and style markdown like Maka
    
    The running status glyph now spins once a second, as the native client's
    does, through the new svg spin. Markdown replies take the native client's
    message text style: heading sizes, the gap between blocks, code blocks on
    the code fill at 14/20, inline code on the wash, and padded table cells.
    
    Generated-by: Claude Code
    
    * fix(gpui-shell): give focus back when the focused element goes away
    
    A pressed control takes focus, and the press can remove it: a button
    that closes the panel it is in, or one that turns disabled. GPUI then
    sends every key to the window root alone, and actions bound further in
    stop answering until the next press. ShellRoot now moves focus to the
    nearest tab stop left on the old focus path when that happens, and
    leaves a deliberate blur alone.
    
    Generated-by: Claude Code
    
    * fix(desktop): keep the shortcuts after a pressed control goes away
    
    The root's focus handle is a tab stop, so the window root gives the
    focus back to it when a pressed control goes away with the focus, such
    as Settings' close button or a form's Submit. Back and Forward also skip
    the task already shown, which skipping a removed task can bring next to
    itself, so such a press is not spent doing nothing.
    
    Generated-by: Claude Code
    
    * fix(desktop): show tool output as the Host kept it
    
    A terminal's output shows its scrollback and then its whole screen, as
    the TUI does; the first screen row is no longer dropped. The Host's own
    note on a cut it made is left out, and only a stream held to its last
    64 KB without such a note loses its partial first line. A command
    started in the background reads as running there instead of failing to
    read output it does not have yet. A long block is no longer cut inside
    a surrogate pair, which made its row fail to render.
    
    Generated-by: Claude Code
    
    * fix(desktop): judge forms, removals and chosen models as the Host does
    
    A required form field is missing only when nothing is chosen: an empty
    text or no options picked is an answer the field's limits judge, as the
    Host and the TUI accept it. A field named __proto__ is kept, and an
    address is checked closer to the Host's URL parser. A refused Remove
    reads the connections again, so trying again can succeed, and closing
    Settings forgets an unanswered Remove. A model chosen in the composer is
    sent as the Host offers it now, and dropped when its connection is
    removed.
    
    Generated-by: Claude Code
    
    * perf(gpui-shell): step spinners at 33 ms instead of every frame
    
    A spinning svg asked for the next animation frame on every paint, so a
    view with a spinner was redrawn at the display rate: 60 or 120 times a
    second, each time running the item renderer of any list in the view.
    The angle now moves in 33 ms steps, as the native Maka client's spinner
    does, and each paint schedules one notification of its view for when
    the next step is due, one timer per view.
    
    Generated-by: Claude Code
    
    * fix(desktop): show the sidebar footer and choose the folder from the composer
    
    The sidebar was as tall as its column but sat below the 56px window
    strip, so its footer (the Host's state and the Settings button) was
    drawn below the window. The sidebar and the settings navigation now
    take the height left under the strip.
    
    With no task selected, the plate showed a folder list under the
    headline, at the top of an otherwise empty pane. It now shows the
    native client's empty state, one line centered over the reading column.
    Where a new task runs moves to the composer's folder chip: its menu
    lists the Host's projects (the Host registers every folder a task
    starts in), most recently used first, and its roots, and "Choose a
    folder…" walks the Host's folders in the same menu.
    
    Generated-by: Claude Code
    
    * feat(desktop): choose a new task's folder in the platform's folder panel
    
    Walking the home folder in the menu meant paging past every hidden
    folder before reaching a project. "Choose a folder…" now shows the
    platform's open panel for folders (`maka-files.chooseFolder`, which
    answers the path only). The desktop then finds that folder in the
    Host's listing, reading `agent.directories` down from the root that
    holds it, since a task starts from the Host's selection. A folder
    outside the Host's roots, or one the Host does not list, is refused
    with the reason.
    
    Generated-by: Claude Code
    
    * feat(gpui-shell): replace a field's placeholder while it lives
    
    A placeholder could be set only when the input or textarea state was
    created, so an application that changes its language while running
    could not translate the text an empty field shows without rebuilding
    the field and its listeners. Both states now take set_placeholder.
    
    Generated-by: Claude Code
    
    * fix(desktop): declare the clipboard and storage the app uses
    
    The manifest granted nothing, so gpui-shell refused the turn footer's Copy
    ("writing the clipboard is not granted") and would refuse saving the
    language and appearance choices to localStorage. The app now declares
    clipboard write and storage.
    
    Generated-by: Claude Code
    
    * feat(desktop): show the app in English, Simplified or Traditional Chinese
    
    Every label, menu, notice and placeholder now comes from one table,
    messages.js, with an English, a Simplified Chinese and a Traditional
    Chinese entry for each of its 325 keys. Wording follows maka-gpui's copy
    where it has the same string, then the TUI's, so the products agree.
    
    Settings opens on a new General page with the interface language and the
    theme. "Follow system" (the default) uses the language the system asks for,
    read by a new `maka-locale` module from maka-ui-runtime (LC_ALL,
    LC_MESSAGES, LANG, then the OS locale), as the TUI does. A choice is kept
    in localStorage and applies at once: the window renders again and the
    placeholders of live fields are replaced with set_placeholder.
    
    Ages and dates follow the language (刚刚, 3分钟前, 前天, 10/17); a turn
    older than a week shows its date and time as maka-gpui does. Text decided
    when something happened, such as a reported error, keeps the language it
    was written in. A new task's first title is "New task" in the current
    language until the automatic title replaces it.
    
    Generated-by: Claude Code
    
    * feat(desktop): pick the permission level and send a failed turn again
    
    The composer gains a permission chip next to the model chip, with
    maka-gpui's three levels and their one-line hints: Read only, Auto and
    Full access (its hint in the warning color). For an open task it calls
    `agent.metadata` with the session's own title and labels, so an
    automatic title stays automatic, and revokes the approvals granted so
    far, as the TUI's sandbox picker does; a stale revision is read again
    once. For a new task the level is applied right after the task is
    created and before its first message, and that message is not sent when
    the Host refuses the level. A catalog page read before such a change can
    arrive after it, so the task view no longer takes an older revision of
    its session from the catalog.
    
    When the newest turn failed, a bar above the composer says so and offers
    "Send again", which sends the message that started the turn once more
    and leaves the draft alone. Picking the run up where it ended
    (`agent.resume`) is not offered: the Host cannot continue a run while
    Computer Use is composed, because binding its tools for the new run
    asks for the live permissions of a run that has not opened yet.
    
    The turn footer follows maka-gpui: outcome, age and a copy icon right
    after them, shown only when the turn has reply text. Sidebar task rows
    line up with their group labels.
    
    Generated-by: Claude Code
    
    * feat(desktop): show why a run failed and where context was compacted
    
    History now carries `failure` rows, with the reason the Host recorded,
    and `compaction` rows. The desktop drew both as assistant replies. A
    failure shows as one line with the failed icon, "Run error: <reason>"
    (the reason stays as the Host wrote it); the interruption and the end of
    a run often record the same reason, which is shown once. A compaction
    shows as the TUI marks it: a rule with "Context compressed (N s)", and
    only once the checkpoint was adopted.
    
    Generated-by: Claude Code
    
    * fix(desktop): say why a Host endpoint is unavailable
    
    When the Host cannot start a plugin, such as after an upgrade whose
    contracts the root's installation no longer matches, the endpoint has no
    registration and every call failed with only "Host endpoint is
    unavailable". The bridge now adds the reason the Host's catalogue gives
    for the failed startup.
    
    Generated-by: Claude Code
    
    * feat(desktop): attach files to a message
    
    The composer's "+" opens the native panel for files. Each chosen file is
    registered in the desktop process under an opaque handle with its size
    and SHA-256 digest, computed off the UI thread; scripts never see the
    path. A file over the Host's 50 MiB limit or one that cannot be read is
    reported and left out, and a message carries at most 8 files.
    
    Sending uploads each file with agent.uploadAttachment (maka-host reads
    the registered file as a bytes resource, by range, failing when the file
    got shorter since it was chosen) and then sends the message with the
    attachment ids. A message may be files alone. A refused or failed upload
    marks its chip, keeps the draft and sends nothing; files already uploaded
    are reused on the next try. Sent messages show their files as chips
    above the bubble, without the lines history adds for them.
    
    Generated-by: Claude Code
    
    * feat(desktop): add WorkHub
    
    A WorkHub row under New task in the sidebar opens the coordinator. Before
    the coordinator exists the pane asks for the folder it works in (the
    native folder panel) and says that the choice is permanent; then it shows
    two tabs.
    
    Chat is the coordinator's conversation, read and sent through the
    coordinator endpoint with the same transcript and composer as a task,
    including the permission chip (as the TUI's sandbox control). The message
    the Host posts when a task ends is shown as a short row naming the task
    and its outcome instead of the raw observation JSON; clicking it opens
    the task. The Host lists tasks by id, not by time, and a task read has no
    title, so the title is looked up in the pages from the first.
    
    Tasks lists the delegated tasks page by page in the Host's order, with
    their status, kept up to date by watching the list and each open task. A task's detail shows
    its status and answer (Markdown, with Read more for long answers) and
    opens the session the task runs in. When a watch stops the pane says so
    and offers Retry.
    
    Generated-by: Claude Code
    
    * feat(desktop): edit WorkHub's subagents in Settings
    
    WorkHub's coordinator learns which model and tools to delegate with only
    from WorkHub presets: it lists them and routes with one. A new Settings
    page, Subagents, lists the presets of the worker the desktop opens and
    edits them as the TUI's page does (workhub.presets / savePresets at the
    revision read): name, description, model, tools (the four profiles or
    one by one), an isolated Git workspace, and whether WorkHub may use it.
    Saving rebuilds every entry with exactly the keys the Host takes and
    keeps other workers' entries; a conflict takes the Host's list and keeps
    the edit open to be saved again; Remove asks first.
    
    A model selection names its connection's revision, and the Host runs it
    only at that revision, so a preset stops working when its connection
    changes (a key saved, models found). The list says so for such a preset,
    and opening it takes the current revision, so saving repairs it.
    
    WorkHub's conversation shows a bar with a button to this page while no
    preset is enabled with a model the Host offers.
    
    Generated-by: Claude Code
    
    * feat(desktop): rename, archive and delete tasks
    
    A task's row in the sidebar gets a "…" button while it is under the
    pointer or selected, and a right-click opens the same menu: Rename (F2),
    Copy task ID, and Archive, which only an idle task offers.
    
    Rename turns the row into a field holding the title, all of it
    selected. Enter or leaving the field saves through agent.metadata with
    the labels kept; Escape leaves it as it was; a name longer than the
    Host's 128 characters says so under the field. The row shows the new
    title until the catalog brings it.
    
    Archive calls agent.changeSession; the row shows the running glyph until
    the catalog drops it, and an open task goes back to the start pane. The
    Host refuses a task that is working or has queued messages, and the
    desktop says to stop it first.
    
    A new Settings page, Archived, lists the archived tasks newest first,
    with Restore, and Delete, which asks in the row first. The words follow
    the TUI's.
    
    Generated-by: Claude Code
    
    * feat(desktop): search what was said in every task
    
    A Search page, opened from a button at the end of the sidebar's wordmark
    row and with ⇧⌘F, searches every task through agent.recall as the TUI's
    "Search conversations" does: the field's words, split on whitespace, are
    searched after a 250 ms pause; an answer to any but the latest search is
    dropped, and the last answer stays, dimmed, while a new one runs. The
    page lists the tasks whose titles match, then the passages that match,
    grouped by task, each cut around its first match with every match
    marked. Archived tasks are found and marked. Words the Host would refuse
    are not sent; a failure says why, with Retry; an answer the Host could
    not complete says so.
    
    Opening a passage opens its task at that message: the transcript reads
    the history page that starts at the passage's row (an `after` cursor, as
    the TUI reads it), then every newer page, and the list opens at that row
    and keeps it in place while older rows load above. It follows the newest
    row again once that row is on screen or a message is sent. Back returns
    to the page with its words and results where they were left.
    
    Up and Down move through the results from the field, Enter opens one
    (or searches the words when they changed), and Escape clears the field,
    then goes back.
    
    Generated-by: Claude Code
    
    * fix(desktop): selectable sent messages; rejected tool calls read as not run
    
    A message the user sent is drawn as a TextView, so it can be selected
    and copied like a reply. The text goes in as escaped HTML, one paragraph
    per line, with runs of spaces and a space starting a line written as
    non-breaking ones, so it still reads as the plain text that was sent.
    A <br> would not do: TextView merges the text around it into one line.
    
    A tool call the Host rejected before dispatch (invalid input, policy,
    or ExclusiveConflict when it shares a step with a Shell call) never ran,
    and the model is told why and goes on. Its row now says "not run" with
    the muted icon and opens to the reason, instead of the red failed
    state. Such a call has no presentation, so the row names the workspace
    tools by the names the model calls them (Read, Write, Edit, ApplyPatch,
    Glob, Grep, Shell).
    
    escapeHtml moves from search.js to theme.js for both.
    
    Generated-by: Claude Code
    
    * style(desktop): format the app with Biome
    
    Biome's formatter covers the whole tree, so the desktop app's scripts
    now follow it. The gpui-shell component inventory stays out of it: it is
    a verbatim gpui-kit file whose hash third-party/gpui-kit/upstream.json
    records.
    
    Generated-by: Claude Code
    
    * chore(notices): regenerate for the desktop's dependencies
    
    notices.mjs walks the CLI and Desktop closures in the workspace-wide
    cargo metadata resolve, so the features the desktop shell turns on in
    shared crates (hdrhistogram through gpui-fast, der 0.8's derive and PEM
    crates) now appear in the notices.
    
    Generated-by: Claude Code
    
    * docs(desktop): describe the shell as the desktop it now is
    
    Generated-by: Claude Code
    
    * fix(desktop): copy a sent message as the text sent
    
    The sent-message bubble wrote each line as a paragraph and the spaces
    HTML folds as non-breaking ones, so a copy gave U+00A0 where the user
    typed spaces, and tabs as four spaces. It is now one `<pre>` with the
    text escaped, which keeps spaces, tabs and blank lines as they are.
    
    That needs a fix to the vendored gpui-kit minifier, which wrote `<pre>`
    text unescaped (escaped markup came back as elements) and lost a line
    feed that starts it (longbridge/gpui-kit#3570; see SOURCE.md).
    
    Generated-by: Claude Code
    
    * fix(desktop): read every model the Host offers
    
    `settings.models` answers 16 choices a page, and the composer and the
    Subagents page read only the first. A model past it could not be chosen
    in the composer, a task using it was named by its id alone, and a
    preset using it read as no longer offered, so WorkHub also said it had
    no subagent to delegate with. Both now read every page through one
    helper, which starts again once when the list changes between pages.
    
    Generated-by: Claude Code
    
    * fix(desktop): offer Send again only for a message it can send
    
    Sending a failed turn again sends its text alone, so for a message that
    carried files it sent something other than what failed. The bar no
    longer offers it for such a message. It is also not offered while newer
    history is still unread, when the last row loaded is not the last turn.
    
    Generated-by: Claude Code
    
    * fix(desktop): mark search words typed decomposed
    
    `agent.recall` composes the terms (NFC) and returns composed excerpts,
    so a word typed with a combining accent was found but not marked. The
    excerpt's pattern composes the terms too.
    
    Generated-by: Claude Code
    
    * docs(gpui-kit): note which backported pull requests have merged
    
    Generated-by: Claude Code
    
    * feat(desktop): follow the Host's WorkHub contract
    
    The desktop had worked around three WorkHub gaps that 2bd51ebb6,
    de2c51a4b and 3f7ee7843 closed, and its presets no longer matched the
    Host's shape:
    
    - A preset names a connection and a model, which the Host resolves when
      it accepts a task. The Subagents page saves that pair and no longer
      marks a preset whose connection changed since it was saved.
    - A task-end notice is recognised by its history `origin`, not by its
      message id and text; a task graph's notice reads as one.
    - A notice's task is named by the title `workhub.task` returns, read
      once, instead of paging the task list for it. The list shows when
      each task was accepted, newest first as the Host pages them.
    
    Generated-by: Claude Code
    
    * fix(desktop): read WorkHub notices recorded before history had origins
    
    History gives a user message's `origin` only for messages recorded
    since de2c51a4b, so on a root that an earlier Host wrote, the task-end
    notices in the coordinator's conversation showed as the user's own
    messages, and one that started a failed turn offered Send again. A row
    without an origin is still a notice when its message id has WorkHub's
    `m_wh_` shape; its task comes from the text. A task title that an
    earlier Host does not return reads as unknown.
    
    Generated-by: Claude Code
    
    * fix(gpui-kit): keep a `<pre>` line feed after a dropped comment
    
    The minifier leaves comments out, so a line feed after a comment that
    opens a `<pre>` ended up right after the tag and the next parse dropped
    it. Text whose earlier siblings are all such comments now counts as
    the first (longbridge/gpui-kit#3570).
    
    Generated-by: Claude Code
    
    * chore(notices): regenerate for the vendored minifier fix
    
    The `<pre>` fixes to the vendored gpui-kit minifier changed the hashes
    of its sources and of SOURCE.md.
    
    Generated-by: Claude Code
---
 Cargo.lock                                         |  1146 +-
 Cargo.toml                                         |     6 +
 biome.jsonc                                        |     5 +-
 products/maka/cli/THIRD_PARTY_NOTICES.txt          |    68 +
 products/maka/cli/notices.json                     |   221 +-
 products/maka/desktop-shell/Cargo.toml             |    36 +
 products/maka/desktop-shell/app/.gitignore         |     2 +
 products/maka/desktop-shell/app/archived.js        |   275 +
 products/maka/desktop-shell/app/composer.js        |   796 ++
 products/maka/desktop-shell/app/directories.js     |   345 +
 products/maka/desktop-shell/app/gpui-shell.json    |    12 +
 products/maka/desktop-shell/app/host.js            |   112 +
 products/maka/desktop-shell/app/icons/activity.svg |    20 +
 products/maka/desktop-shell/app/icons/archive.svg  |    20 +
 products/maka/desktop-shell/app/icons/attach.svg   |    20 +
 products/maka/desktop-shell/app/icons/check.svg    |    20 +
 .../maka/desktop-shell/app/icons/chevron-down.svg  |    20 +
 .../maka/desktop-shell/app/icons/chevron-left.svg  |    20 +
 .../maka/desktop-shell/app/icons/chevron-right.svg |    20 +
 products/maka/desktop-shell/app/icons/close.svg    |    20 +
 products/maka/desktop-shell/app/icons/compose.svg  |    20 +
 products/maka/desktop-shell/app/icons/copy.svg     |    20 +
 products/maka/desktop-shell/app/icons/cpu.svg      |    19 +
 .../maka/desktop-shell/app/icons/file-diff.svg     |    20 +
 products/maka/desktop-shell/app/icons/flag.svg     |    20 +
 .../maka/desktop-shell/app/icons/folder-open.svg   |    20 +
 products/maka/desktop-shell/app/icons/folder.svg   |    20 +
 products/maka/desktop-shell/app/icons/host.svg     |    20 +
 products/maka/desktop-shell/app/icons/more.svg     |    20 +
 products/maka/desktop-shell/app/icons/plus.svg     |    20 +
 products/maka/desktop-shell/app/icons/queue.svg    |    20 +
 products/maka/desktop-shell/app/icons/search.svg   |    20 +
 products/maka/desktop-shell/app/icons/send.svg     |    20 +
 products/maka/desktop-shell/app/icons/settings.svg |    20 +
 products/maka/desktop-shell/app/icons/sidebar.svg  |    20 +
 .../maka/desktop-shell/app/icons/status-done.svg   |    20 +
 .../maka/desktop-shell/app/icons/status-failed.svg |    20 +
 .../desktop-shell/app/icons/status-running.svg     |    20 +
 .../desktop-shell/app/icons/status-stopped.svg     |    20 +
 .../desktop-shell/app/icons/status-waiting.svg     |    20 +
 products/maka/desktop-shell/app/icons/stop.svg     |    20 +
 .../maka/desktop-shell/app/icons/tool-edit.svg     |    20 +
 .../maka/desktop-shell/app/icons/tool-plug.svg     |    20 +
 .../maka/desktop-shell/app/icons/tool-read.svg     |    20 +
 .../maka/desktop-shell/app/icons/tool-search.svg   |    20 +
 .../maka/desktop-shell/app/icons/tool-terminal.svg |    20 +
 products/maka/desktop-shell/app/icons/tool-web.svg |    20 +
 .../desktop-shell/app/icons/triangle-alert.svg     |    20 +
 products/maka/desktop-shell/app/icons/wordmark.svg |    20 +
 products/maka/desktop-shell/app/icons/workhub.svg  |    20 +
 products/maka/desktop-shell/app/interactions.js    |   889 ++
 products/maka/desktop-shell/app/jsconfig.json      |    32 +
 products/maka/desktop-shell/app/locale.js          |   114 +
 products/maka/desktop-shell/app/main.js            |   903 ++
 products/maka/desktop-shell/app/messages.js        |  1178 ++
 products/maka/desktop-shell/app/search.js          |   712 ++
 products/maka/desktop-shell/app/sessions.js        |   920 ++
 products/maka/desktop-shell/app/settings.js        |  1470 +++
 products/maka/desktop-shell/app/subagents.js       |   844 ++
 products/maka/desktop-shell/app/theme.js           |   581 +
 products/maka/desktop-shell/app/transcript.js      |  1949 +++
 products/maka/desktop-shell/app/workhub.js         |  1039 ++
 products/maka/desktop-shell/src/files.rs           |   550 +
 products/maka/desktop-shell/src/locale.rs          |    43 +
 products/maka/desktop-shell/src/main.rs            |    59 +
 products/maka/desktop-shell/src/maka_host.rs       |   959 ++
 products/maka/desktop-shell/src/window.rs          |    76 +
 scripts/asf-license-headers.mjs                    |     1 +
 third-party/gpui-fast/SOURCE.md                    |     4 +
 third-party/gpui-fast/SOURCE.zh-CN.md              |     4 +
 .../gpui-fast/crates/gpui/src/fast/layers/lists.rs |   175 +-
 .../gpui-fast/crates/gpui/src/fast/layout.rs       |     8 +
 .../crates/gpui/src/fast/tests/layers_lists.rs     |   617 +
 third-party/gpui-kit/SOURCE.md                     |    59 +-
 .../crates/base/src/input/editor/highlighting.rs   |    16 +-
 .../gpui-kit/crates/base/src/text/format/html.rs   |    25 +
 .../crates/base/src/text/format/html5minify/mod.rs |    19 +-
 third-party/gpui-kit/crates/shell/Cargo.toml       |   205 +
 third-party/gpui-kit/crates/shell/README.md        |   657 +
 .../gpui-kit/crates/shell/component-inventory.json |  2696 +++++
 .../crates/shell/rquickjs-compat/Cargo.toml        |    55 +
 .../crates/shell/rquickjs-compat/src/lib.rs        |    11 +
 .../crates/shell/rquickjs-compat/tests/identity.rs |    34 +
 third-party/gpui-kit/crates/shell/src/a11y.rs      |   156 +
 third-party/gpui-kit/crates/shell/src/action.rs    |   107 +
 third-party/gpui-kit/crates/shell/src/assets.rs    |   300 +
 .../gpui-kit/crates/shell/src/bin/gpui-shell.rs    |    27 +
 .../gpui-kit/crates/shell/src/capability.rs        |   820 ++
 third-party/gpui-kit/crates/shell/src/component.rs |    73 +
 .../crates/shell/src/component_registry.rs         |  3177 +++++
 .../gpui-kit/crates/shell/src/components/mod.rs    |   493 +
 .../src/components/shell/basic/dropdown_button.rs  |   290 +
 .../crates/shell/src/components/shell/basic/mod.rs |    47 +
 .../shell/src/components/shell/basic/text.rs       |    71 +
 .../crates/shell/src/components/shell/carousel.rs  |   717 ++
 .../crates/shell/src/components/shell/chart/mod.rs |   396 +
 .../crates/shell/src/components/shell/chat.rs      |   511 +
 .../shell/src/components/shell/collections/mod.rs  |    43 +
 .../shell/src/components/shell/collections/tree.rs |   321 +
 .../shell/src/components/shell/command/command.rs  |   336 +
 .../shell/src/components/shell/command/mod.rs      |    59 +
 .../src/components/shell/command/native_menu.rs    |   252 +
 .../shell/src/components/shell/compound/avatar.rs  |   120 +
 .../src/components/shell/compound/collapsible.rs   |    75 +
 .../shell/src/components/shell/compound/common.rs  |    56 +
 .../shell/src/components/shell/compound/mod.rs     |    89 +
 .../src/components/shell/compound/pagination.rs    |   196 +
 .../src/components/shell/compound/progress.rs      |   165 +
 .../shell/src/components/shell/compound/radio.rs   |   186 +
 .../shell/src/components/shell/controls/action.rs  |   399 +
 .../shell/src/components/shell/controls/display.rs |   296 +
 .../shell/src/components/shell/controls/mod.rs     |    65 +
 .../shell/src/components/shell/controls/support.rs |    69 +
 .../shell/src/components/shell/controls/text.rs    |   228 +
 .../shell/src/components/shell/data_table/mod.rs   |   307 +
 .../components/shell/delegate_collections/list.rs  |   213 +
 .../components/shell/delegate_collections/mod.rs   |    11 +
 .../src/components/shell/delegate_combobox/mod.rs  |   356 +
 .../src/components/shell/delegate_select/mod.rs    |   302 +
 .../shell/src/components/shell/display/alert.rs    |   147 +
 .../src/components/shell/display/breadcrumb.rs     |    82 +
 .../src/components/shell/display/clipboard.rs      |   125 +
 .../shell/src/components/shell/display/common.rs   |    55 +
 .../src/components/shell/display/group_box.rs      |   118 +
 .../shell/src/components/shell/display/mod.rs      |    71 +
 .../shell/src/components/shell/display/rating.rs   |   227 +
 .../src/components/shell/display/status_bar.rs     |    83 +
 .../src/components/shell/display/title_bar.rs      |   109 +
 .../shell/src/components/shell/display/toolbar.rs  |    74 +
 .../crates/shell/src/components/shell/empty.rs     |   299 +
 .../src/components/shell/input_group/binding.rs    |   259 +
 .../components/shell/input_group/content_type.rs   |   107 +
 .../shell/src/components/shell/input_group/mod.rs  |   609 +
 .../shell/src/components/shell/input_tokens.rs     |   202 +
 .../shell/src/components/shell/layout/mod.rs       |    51 +
 .../shell/src/components/shell/layout/resizable.rs |   206 +
 .../shell/src/components/shell/layout/textarea.rs  |   110 +
 .../shell/src/components/shell/lifecycle/menu.rs   |   292 +
 .../shell/src/components/shell/lifecycle/mod.rs    |    19 +
 .../src/components/shell/lifecycle/tooltip.rs      |   102 +
 .../shell/src/components/shell/media/diff.rs       |   199 +
 .../shell/src/components/shell/media/editor.rs     |   135 +
 .../shell/src/components/shell/media/image.rs      |    94 +
 .../crates/shell/src/components/shell/media/mod.rs |    20 +
 .../crates/shell/src/components/shell/mod.rs       |    72 +
 .../shell/src/components/shell/navigation/icon.rs  |   159 +
 .../shell/src/components/shell/navigation/mod.rs   |    48 +
 .../src/components/shell/navigation/sidebar.rs     |   522 +
 .../src/components/shell/overlays/dropdown_menu.rs |   160 +
 .../src/components/shell/overlays/hover_card.rs    |   328 +
 .../shell/src/components/shell/overlays/mod.rs     |    26 +
 .../shell/src/components/shell/overlays/popover.rs |   214 +
 .../src/components/shell/questionnaire/mod.rs      |   506 +
 .../src/components/shell/retained_forms/mod.rs     |   697 ++
 .../shell/src/components/shell/scroll/mod.rs       |    48 +
 .../shell/src/components/shell/scroll/scroll.rs    |   292 +
 .../crates/shell/src/components/shell/separator.rs |   166 +
 .../shell/src/components/shell/settings/mod.rs     |   256 +
 .../crates/shell/src/components/shell/skeleton.rs  |    96 +
 .../crates/shell/src/components/shell/spinner.rs   |   193 +
 .../src/components/shell/structured/common.rs      |   127 +
 .../shell/structured/description_list.rs           |   344 +
 .../shell/src/components/shell/structured/form.rs  |   274 +
 .../shell/src/components/shell/structured/mod.rs   |    62 +
 .../shell/src/components/shell/structured/table.rs |   337 +
 .../crates/shell/src/components/shell/support.rs   |   174 +
 .../shell/src/components/shell/typed_child.rs      |   114 +
 .../src/components/shell/typed_compound/mod.rs     |   993 ++
 .../src/components/shell/window_effects/mod.rs     |   704 ++
 .../gpui-kit/crates/shell/src/default-tokens.json  |    44 +
 .../gpui-kit/crates/shell/src/dependencies.rs      |  1242 ++
 third-party/gpui-kit/crates/shell/src/dock.rs      |  1453 +++
 .../gpui-kit/crates/shell/src/engine/mod.rs        |   155 +
 .../crates/shell/src/engine/quickjs/dock_api.rs    |  1151 ++
 .../crates/shell/src/engine/quickjs/entity_api.rs  |  1701 +++
 .../crates/shell/src/engine/quickjs/host.rs        |  1355 +++
 .../shell/src/engine/quickjs/host_modules.rs       |   517 +
 .../crates/shell/src/engine/quickjs/mod.rs         | 11843 +++++++++++++++++++
 .../crates/shell/src/engine/quickjs/overlay.rs     |   974 ++
 .../crates/shell/src/engine/quickjs/sandbox.rs     |   828 ++
 .../crates/shell/src/engine/quickjs/scheduler.rs   |  2029 ++++
 .../shell/src/engine/quickjs/standard/connect.rs   |   322 +
 .../shell/src/engine/quickjs/standard/console.rs   |    24 +
 .../shell/src/engine/quickjs/standard/fetch.rs     |   658 ++
 .../crates/shell/src/engine/quickjs/standard/fs.rs |    43 +
 .../shell/src/engine/quickjs/standard/mod.rs       |    74 +
 .../shell/src/engine/quickjs/standard/net.rs       |   147 +
 .../crates/shell/src/engine/quickjs/standard/os.rs |    31 +
 .../shell/src/engine/quickjs/standard/process.rs   |    28 +
 .../shell/src/engine/quickjs/standard/websocket.rs |   534 +
 .../crates/shell/src/engine/quickjs/template.rs    |   567 +
 .../crates/shell/src/engine/quickjs/theme_api.rs   |   427 +
 .../crates/shell/src/engine/quickjs/window_api.rs  |   369 +
 third-party/gpui-kit/crates/shell/src/entities.rs  |  1481 +++
 third-party/gpui-kit/crates/shell/src/error.rs     |    31 +
 third-party/gpui-kit/crates/shell/src/host.rs      |  1751 +++
 .../gpui-kit/crates/shell/src/host/agents.md       |   175 +
 .../gpui-kit/crates/shell/src/host/agents.rs       |   163 +
 .../gpui-kit/crates/shell/src/host/bundle.rs       |  1337 +++
 .../crates/shell/src/host/bundle/linux-install.sh  |   203 +
 .../crates/shell/src/host/bundle/linux-launcher.sh |    30 +
 .../crates/shell/src/host/bundle/macos-launcher.sh |    32 +
 .../crates/shell/src/host/bundle/runtime.sh        |   104 +
 .../gpui-kit/crates/shell/src/host/installed.rs    |   229 +
 .../gpui-kit/crates/shell/src/host/scaffold.rs     |   229 +
 .../gpui-kit/crates/shell/src/host_modules.rs      |  1339 +++
 .../gpui-kit/crates/shell/src/input_tokens.rs      |   412 +
 third-party/gpui-kit/crates/shell/src/lib.rs       |   454 +
 .../gpui-kit/crates/shell/src/materialize.rs       |  3579 ++++++
 .../crates/shell/src/materialize/components.rs     |    46 +
 .../shell/src/materialize/components/accordion.rs  |   267 +
 .../shell/src/materialize/components/avatar.rs     |   167 +
 .../src/materialize/components/collapsible.rs      |    61 +
 .../shell/src/materialize/components/dock.rs       |   258 +
 .../crates/shell/src/materialize/components/fps.rs |    26 +
 .../shell/src/materialize/components/group.rs      |    68 +
 .../shell/src/materialize/components/list.rs       |   523 +
 .../src/materialize/components/number_input.rs     |   318 +
 .../shell/src/materialize/components/otp_input.rs  |   310 +
 .../shell/src/materialize/components/pagination.rs |    65 +
 .../shell/src/materialize/components/popover.rs    |   277 +
 .../shell/src/materialize/components/progress.rs   |    88 +
 .../shell/src/materialize/components/radio.rs      |    78 +
 .../shell/src/materialize/components/resizable.rs  |   172 +
 .../shell/src/materialize/components/scrollbar.rs  |   298 +
 .../shell/src/materialize/components/select.rs     |   311 +
 .../shell/src/materialize/components/slider.rs     |   404 +
 .../shell/src/materialize/components/spin.rs       |   330 +
 .../shell/src/materialize/components/table.rs      |   194 +
 .../shell/src/materialize/components/tabs.rs       |    80 +
 .../shell/src/materialize/components/textarea.rs   |    87 +
 .../shell/src/materialize/components/toggle.rs     |    72 +
 .../shell/src/materialize/components/tooltip.rs    |   172 +
 .../src/materialize/components/virtual_list.rs     |   386 +
 .../crates/shell/src/materialize/text_view.rs      |   366 +
 .../shell/src/materialize/text_view/style.rs       |   708 ++
 .../shell/src/materialize/text_view/tests.rs       |   395 +
 third-party/gpui-kit/crates/shell/src/metrics.rs   |   332 +
 third-party/gpui-kit/crates/shell/src/path.rs      |   219 +
 third-party/gpui-kit/crates/shell/src/plugin.rs    |  3114 +++++
 third-party/gpui-kit/crates/shell/src/policy.rs    |   362 +
 third-party/gpui-kit/crates/shell/src/process.rs   |   678 ++
 third-party/gpui-kit/crates/shell/src/root.rs      |  1725 +++
 third-party/gpui-kit/crates/shell/src/runtime.rs   |  1054 ++
 third-party/gpui-kit/crates/shell/src/scope.rs     |   472 +
 .../gpui-kit/crates/shell/src/script_callback.rs   |    66 +
 third-party/gpui-kit/crates/shell/src/scroll.rs    |   287 +
 third-party/gpui-kit/crates/shell/src/snapshot.rs  |   157 +
 third-party/gpui-kit/crates/shell/src/spec.rs      |  1630 +++
 third-party/gpui-kit/crates/shell/src/storage.rs   |   808 ++
 third-party/gpui-kit/crates/shell/src/style.rs     |   856 ++
 .../gpui-kit/crates/shell/src/tests/benchmark.rs   |  1005 ++
 .../gpui-kit/crates/shell/src/tests/dock.rs        |   633 +
 third-party/gpui-kit/crates/shell/src/tests/fs.rs  |   486 +
 .../gpui-kit/crates/shell/src/tests/host_api.rs    |   320 +
 .../crates/shell/src/tests/http_request.rs         |   317 +
 third-party/gpui-kit/crates/shell/src/tests/mod.rs |    24 +
 .../gpui-kit/crates/shell/src/tests/network.rs     |  1197 ++
 .../gpui-kit/crates/shell/src/tests/process.rs     |   260 +
 .../gpui-kit/crates/shell/src/tests/render.rs      | 11383 ++++++++++++++++++
 .../gpui-kit/crates/shell/src/tests/snapshot.rs    |   802 ++
 .../gpui-kit/crates/shell/src/tests/spin.rs        |   386 +
 .../crates/shell/src/tests/standard_runtime.rs     |   219 +
 .../gpui-kit/crates/shell/src/tests/structure.rs   |   695 ++
 .../gpui-kit/crates/shell/src/tests/template.rs    |   722 ++
 .../gpui-kit/crates/shell/src/tests/text_style.rs  |   243 +
 .../gpui-kit/crates/shell/src/theme_tokens.rs      |   214 +
 third-party/gpui-kit/crates/shell/src/typings.rs   |  5216 ++++++++
 third-party/gpui-kit/crates/shell/src/value.rs     |   166 +
 third-party/gpui-kit/crates/shell/src/view.rs      |   321 +
 third-party/gpui-kit/crates/shell/src/watch.rs     |   753 ++
 third-party/gpui-kit/upstream.json                 |   192 +-
 272 files changed, 124517 insertions(+), 112 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 9499b80508..9bebccfd9c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -88,7 +88,7 @@ version = "0.25.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
 dependencies = [
- "gimli",
+ "gimli 0.32.3",
 ]
 
 [[package]]
@@ -149,11 +149,24 @@ dependencies = [
  "aead 0.5.2",
  "aes 0.8.3",
  "cipher 0.4.4",
- "ctr",
- "ghash",
+ "ctr 0.9.2",
+ "ghash 0.5.1",
  "subtle",
 ]
 
+[[package]]
+name = "aes-gcm"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f"
+dependencies = [
+ "aead 0.6.1",
+ "cipher 0.5.2",
+ "ctr 0.10.1",
+ "ctutils",
+ "ghash 0.6.0",
+]
+
 [[package]]
 name = "aes-kw"
 version = "0.2.1"
@@ -163,6 +176,16 @@ dependencies = [
  "aes 0.8.3",
 ]
 
+[[package]]
+name = "aes-kw"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41ac571010bd60765c56085a4f1d412012a9be2663b1a2f2b19b49318653fd0d"
+dependencies = [
+ "aes 0.9.3",
+ "const-oid 0.10.2",
+]
+
 [[package]]
 name = "agent-client-protocol"
 version = "2.2.0"
@@ -963,7 +986,7 @@ dependencies = [
  "cfg-if",
  "libc",
  "miniz_oxide 0.8.9",
- "object",
+ "object 0.37.3",
  "rustc-demangle",
  "windows-link 0.2.1",
 ]
@@ -1339,6 +1362,9 @@ name = "bumpalo"
 version = "3.20.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+dependencies = [
+ "allocator-api2",
+]
 
 [[package]]
 name = "by_address"
@@ -2197,6 +2223,140 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "cranelift-bforest"
+version = "0.116.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e15d04a0ce86cb36ead88ad68cf693ffd6cda47052b9e0ac114bc47fd9cd23c4"
+dependencies = [
+ "cranelift-entity",
+]
+
+[[package]]
+name = "cranelift-bitset"
+version = "0.116.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c6e3969a7ce267259ce244b7867c5d3bc9e65b0a87e81039588dfdeaede9f34"
+dependencies = [
+ "serde",
+ "serde_derive",
+]
+
+[[package]]
+name = "cranelift-codegen"
+version = "0.116.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c22032c4cb42558371cf516bb47f26cdad1819d3475c133e93c49f50ebf304e"
+dependencies = [
+ "bumpalo",
+ "cranelift-bforest",
+ "cranelift-bitset",
+ "cranelift-codegen-meta",
+ "cranelift-codegen-shared",
+ "cranelift-control",
+ "cranelift-entity",
+ "cranelift-isle",
+ "gimli 0.31.1",
+ "hashbrown 0.14.5",
+ "log",
+ "regalloc2",
+ "rustc-hash 2.1.3",
+ "serde",
+ "serde_derive",
+ "smallvec",
+ "target-lexicon",
+]
+
+[[package]]
+name = "cranelift-codegen-meta"
+version = "0.116.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c904bc71c61b27fc57827f4a1379f29de64fe95653b620a3db77d59655eee0b8"
+dependencies = [
+ "cranelift-codegen-shared",
+]
+
+[[package]]
+name = "cranelift-codegen-shared"
+version = "0.116.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40180f5497572f644ce88c255480981ae2ec1d7bb4d8e0c0136a13b87a2f2ceb"
+
+[[package]]
+name = "cranelift-control"
+version = "0.116.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26d132c6d0bd8a489563472afc171759da0707804a65ece7ceb15a8c6d7dd5ef"
+dependencies = [
+ "arbitrary",
+]
+
+[[package]]
+name = "cranelift-entity"
+version = "0.116.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b2d0d9618275474fbf679dd018ac6e009acbd6ae6850f6a67be33fb3b00b323"
+dependencies = [
+ "cranelift-bitset",
+ "serde",
+ "serde_derive",
+]
+
+[[package]]
+name = "cranelift-frontend"
+version = "0.116.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fac41e16729107393174b0c9e3730fb072866100e1e64e80a1a963b2e484d57"
+dependencies = [
+ "cranelift-codegen",
+ "log",
+ "smallvec",
+ "target-lexicon",
+]
+
+[[package]]
+name = "cranelift-isle"
+version = "0.116.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ca20d576e5070044d0a72a9effc2deacf4d6aa650403189d8ea50126483944d"
+
+[[package]]
+name = "cranelift-module"
+version = "0.116.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d55612bebcf16ff7306c8a6f5bdb6d45662b8aa1ee058ecce8807ad87db719b"
+dependencies = [
+ "anyhow",
+ "cranelift-codegen",
+ "cranelift-control",
+]
+
+[[package]]
+name = "cranelift-native"
+version = "0.116.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8dee82f3f1f2c4cba9177f1cc5e350fe98764379bcd29340caa7b01f85076c7"
+dependencies = [
+ "cranelift-codegen",
+ "libc",
+ "target-lexicon",
+]
+
+[[package]]
+name = "cranelift-object"
+version = "0.116.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aad5a6d3e379493c3f8b35dc61c93d0bf5f27003bbe20614e0200b0ec372ef52"
+dependencies = [
+ "anyhow",
+ "cranelift-codegen",
+ "cranelift-control",
+ "cranelift-module",
+ "log",
+ "object 0.36.7",
+ "target-lexicon",
+]
+
 [[package]]
 name = "crc"
 version = "3.4.0"
@@ -2212,6 +2372,15 @@ version = "2.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
 
+[[package]]
+name = "crc32c"
+version = "0.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47"
+dependencies = [
+ "rustc_version",
+]
+
 [[package]]
 name = "crc32fast"
 version = "1.5.1"
@@ -2329,6 +2498,7 @@ dependencies = [
  "hybrid-array",
  "num-traits",
  "rand_core 0.10.1",
+ "serdect 0.4.3",
  "subtle",
  "zeroize",
 ]
@@ -2355,6 +2525,16 @@ dependencies = [
  "rand_core 0.10.1",
 ]
 
+[[package]]
+name = "crypto-primes"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f"
+dependencies = [
+ "crypto-bigint 0.7.5",
+ "rand_core 0.10.1",
+]
+
 [[package]]
 name = "cssparser"
 version = "0.36.0"
@@ -2420,6 +2600,15 @@ dependencies = [
  "cipher 0.4.4",
 ]
 
+[[package]]
+name = "ctr"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21"
+dependencies = [
+ "cipher 0.5.2",
+]
+
 [[package]]
 name = "ctutils"
 version = "0.4.2"
@@ -2537,7 +2726,24 @@ dependencies = [
  "cfg-if",
  "cpufeatures 0.2.17",
  "curve25519-dalek-derive",
- "fiat-crypto",
+ "fiat-crypto 0.2.9",
+ "rustc_version",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "curve25519-dalek"
+version = "5.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.1",
+ "curve25519-dalek-derive",
+ "digest 0.11.3",
+ "fiat-crypto 0.3.0",
+ "rand_core 0.10.1",
  "rustc_version",
  "subtle",
  "zeroize",
@@ -2703,19 +2909,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b561b3e978d2e33e48faa609476a6e227eb101e404920f7b3f539ee33d75cb1e"
 dependencies = [
  "aes 0.8.3",
- "aes-gcm",
- "aes-kw",
+ "aes-gcm 0.10.3",
+ "aes-kw 0.2.1",
  "argon2",
  "aws-lc-rs",
  "base64 0.22.1",
  "cbc 0.1.2",
  "const-oid 0.9.6",
- "ctr",
- "curve25519-dalek",
+ "ctr 0.9.2",
+ "curve25519-dalek 4.1.3",
  "deno_core",
  "deno_crypto_provider",
  "deno_error",
- "ecdsa",
+ "ecdsa 0.16.9",
  "elliptic-curve 0.13.8",
  "fips203",
  "fips205",
@@ -2723,11 +2929,11 @@ dependencies = [
  "num-traits",
  "ocb3",
  "once_cell",
- "p256",
- "p384",
- "p521",
+ "p256 0.13.2",
+ "p384 0.13.1",
+ "p521 0.13.3",
  "rand 0.8.5",
- "rsa",
+ "rsa 0.9.10",
  "serde",
  "serde_bytes",
  "sha1 0.10.7",
@@ -2740,7 +2946,7 @@ dependencies = [
  "tiny-keccak",
  "tokio",
  "uuid",
- "x25519-dalek",
+ "x25519-dalek 2.0.1",
 ]
 
 [[package]]
@@ -2892,8 +3098,8 @@ version = "0.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "86bc737e098a45aa5742d51ce694ac7236a1e69fb0d9df8c862e9b4c9583c5f9"
 dependencies = [
- "dlopen2",
- "dlopen2_derive",
+ "dlopen2 0.7.0",
+ "dlopen2_derive 0.4.3",
  "once_cell",
  "rustls-native-certs 0.7.3",
  "rustls-pemfile",
@@ -3157,7 +3363,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
 dependencies = [
  "const-oid 0.9.6",
- "pem-rfc7468",
+ "pem-rfc7468 0.7.0",
  "zeroize",
 ]
 
@@ -3168,6 +3374,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a"
 dependencies = [
  "const-oid 0.10.2",
+ "der_derive",
+ "pem-rfc7468 1.0.0",
  "zeroize",
 ]
 
@@ -3185,6 +3393,17 @@ dependencies = [
  "rusticata-macros",
 ]
 
+[[package]]
+name = "der_derive"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59600e2c2d636fde9b65e99cc6445ac770c63d3628195ff39932b8d6d7409903"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
 [[package]]
 name = "deranged"
 version = "0.5.8"
@@ -3378,12 +3597,23 @@ version = "0.7.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9e1297103d2bbaea85724fcee6294c2d50b1081f9ad47d0f6f6f61eda65315a6"
 dependencies = [
- "dlopen2_derive",
+ "dlopen2_derive 0.4.3",
  "libc",
  "once_cell",
  "winapi",
 ]
 
+[[package]]
+name = "dlopen2"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60768c353d76ba6dd1b6332a5dbbdd3bc2dddadc2935f4cc6a9d0f17ca8ecb6a"
+dependencies = [
+ "dlopen2_derive 0.5.0",
+ "libc",
+ "windows-sys 0.61.2",
+]
+
 [[package]]
 name = "dlopen2_derive"
 version = "0.4.3"
@@ -3395,6 +3625,17 @@ dependencies = [
  "syn 2.0.119",
 ]
 
+[[package]]
+name = "dlopen2_derive"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aacdd87ba97c2f83ff479e881e547f776b0a7c42921be22d4391f32b6284050"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.5",
+]
+
 [[package]]
 name = "document-features"
 version = "0.2.12"
@@ -3508,11 +3749,51 @@ dependencies = [
  "der 0.7.10",
  "digest 0.10.7",
  "elliptic-curve 0.13.8",
- "rfc6979",
+ "rfc6979 0.4.0",
  "signature 2.2.0",
  "spki 0.7.3",
 ]
 
+[[package]]
+name = "ecdsa"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0"
+dependencies = [
+ "der 0.8.2",
+ "digest 0.11.3",
+ "elliptic-curve 0.14.1",
+ "rfc6979 0.6.0",
+ "signature 3.0.0",
+ "zeroize",
+]
+
+[[package]]
+name = "ed25519"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
+dependencies = [
+ "pkcs8 0.11.0",
+ "signature 3.0.0",
+]
+
+[[package]]
+name = "ed25519-dalek"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
+dependencies = [
+ "curve25519-dalek 5.0.0",
+ "ed25519",
+ "rand_core 0.10.1",
+ "serde",
+ "sha2 0.11.1",
+ "signature 3.0.0",
+ "subtle",
+ "zeroize",
+]
+
 [[package]]
 name = "ed448"
 version = "0.5.0"
@@ -3562,7 +3843,7 @@ dependencies = [
  "generic-array",
  "group 0.13.0",
  "hkdf 0.12.4",
- "pem-rfc7468",
+ "pem-rfc7468 0.7.0",
  "pkcs8 0.10.2",
  "rand_core 0.6.4",
  "sec1 0.7.3",
@@ -3581,8 +3862,10 @@ dependencies = [
  "base16ct 1.0.0",
  "crypto-bigint 0.7.5",
  "crypto-common 0.2.2",
+ "digest 0.11.3",
  "ff 0.14.0",
  "group 0.14.0",
+ "hkdf 0.13.0",
  "hybrid-array",
  "pkcs8 0.11.0",
  "rand_core 0.10.1",
@@ -3955,6 +4238,12 @@ version = "0.2.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
 
+[[package]]
+name = "fiat-crypto"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
+
 [[package]]
 name = "filedescriptor"
 version = "0.8.3"
@@ -4035,6 +4324,15 @@ version = "0.9.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
 
+[[package]]
+name = "float-cmp"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8"
+dependencies = [
+ "num-traits",
+]
+
 [[package]]
 name = "float-ord"
 version = "0.3.2"
@@ -4499,7 +4797,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
 dependencies = [
  "opaque-debug",
- "polyval",
+ "polyval 0.6.2",
+]
+
+[[package]]
+name = "ghash"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5"
+dependencies = [
+ "polyval 0.7.3",
 ]
 
 [[package]]
@@ -4522,6 +4829,17 @@ dependencies = [
  "weezl",
 ]
 
+[[package]]
+name = "gimli"
+version = "0.31.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
+dependencies = [
+ "fallible-iterator",
+ "indexmap 2.14.2",
+ "stable_deref_trait",
+]
+
 [[package]]
 name = "gimli"
 version = "0.32.3"
@@ -5599,6 +5917,7 @@ dependencies = [
  "gpui-pre-shared-string",
  "gpui-pre-sum-tree",
  "gpui-pre-util",
+ "hdrhistogram",
  "heapless 0.9.3",
  "image",
  "inventory",
@@ -5877,6 +6196,24 @@ dependencies = [
  "windows-registry 0.6.1",
 ]
 
+[[package]]
+name = "gpui-fps"
+version = "0.7.3"
+source = "git+https://github.com/longbridge/gpui-kit?rev=c5f8eca4a0759f8c0c7ed624eddfd03f1c93a5f2#c5f8eca4a0759f8c0c7ed624eddfd03f1c93a5f2"
+dependencies = [
+ "core-graphics 0.24.0",
+ "gpui-fast",
+ "gpui-pre",
+ "libc",
+ "objc2-core-foundation",
+ "objc2-io-kit",
+ "sysinfo 0.37.2",
+ "uuid",
+ "wayland-client",
+ "web-time",
+ "windows 0.58.0",
+]
+
 [[package]]
 name = "gpui-kit"
 version = "0.7.3"
@@ -5937,6 +6274,7 @@ dependencies = [
  "gpui-pre-shared-string",
  "gpui-pre-sum-tree",
  "gpui-pre-util",
+ "hdrhistogram",
  "heapless 0.9.3",
  "image",
  "inventory",
@@ -6205,6 +6543,7 @@ dependencies = [
  "base64 0.22.1",
  "bytes",
  "encoding_rs",
+ "futures-channel",
  "futures-core",
  "futures-util",
  "h2",
@@ -6418,20 +6757,58 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4b0d73e1bc02eef886a5947f41b8512708792e9e5d3053d181843311a487f96f"
 
 [[package]]
-name = "granit-parser"
-version = "1.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e20f99e46474f56bd905c56e817ebddcf377a611f94c53ac4649e4d3fa3c0cd0"
-dependencies = [
- "arraydeque",
- "smallvec",
-]
-
-[[package]]
-name = "grep-matcher"
-version = "0.1.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f9417543f4870fc8f1c8e1af870afae2431007626d9e703fce6471c468d33847"
+name = "gpui-shell"
+version = "0.7.3"
+dependencies = [
+ "anyhow",
+ "async-channel",
+ "cap-std",
+ "chrono",
+ "flate2",
+ "fs2",
+ "gpui-base",
+ "gpui-fast",
+ "gpui-fast-platform",
+ "gpui-fps",
+ "gpui-pre",
+ "gpui-pre-platform",
+ "gpui-pre-reqwest",
+ "image",
+ "instant",
+ "libc",
+ "quickjs-jit",
+ "quickjs-jit-runtime",
+ "quickjs-jit-stdlib",
+ "schemars 1.2.2",
+ "semver",
+ "serde",
+ "serde_json",
+ "sha2 0.10.9",
+ "smallvec",
+ "smol",
+ "tar",
+ "tracing",
+ "tungstenite 0.29.0",
+ "usvg 0.46.0",
+ "wait-timeout",
+ "windows 0.58.0",
+]
+
+[[package]]
+name = "granit-parser"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e20f99e46474f56bd905c56e817ebddcf377a611f94c53ac4649e4d3fa3c0cd0"
+dependencies = [
+ "arraydeque",
+ "smallvec",
+]
+
+[[package]]
+name = "grep-matcher"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9417543f4870fc8f1c8e1af870afae2431007626d9e703fce6471c468d33847"
 dependencies = [
  "memchr",
 ]
@@ -6526,6 +6903,15 @@ dependencies = [
  "zerocopy",
 ]
 
+[[package]]
+name = "halfbrown"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c7ed2f2edad8a14c8186b847909a41fbb9c3eafa44f88bd891114ed5019da09"
+dependencies = [
+ "hashbrown 0.16.1",
+]
+
 [[package]]
 name = "harfrust"
 version = "0.5.2"
@@ -6631,6 +7017,16 @@ dependencies = [
  "hashbrown 0.17.1",
 ]
 
+[[package]]
+name = "hdrhistogram"
+version = "7.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f49d1053f4708f0af3cf9fc5bffc7e68a914a3c45becb231c80068c9c3f78bea"
+dependencies = [
+ "byteorder",
+ "num-traits",
+]
+
 [[package]]
 name = "heapless"
 version = "0.7.17"
@@ -6689,6 +7085,16 @@ version = "0.4.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
 
+[[package]]
+name = "hex-simd"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f7685beb53fc20efc2605f32f5d51e9ba18b8ef237961d1760169d2290d3bee"
+dependencies = [
+ "outref",
+ "vsimd",
+]
+
 [[package]]
 name = "hexf-parse"
 version = "0.2.1"
@@ -7878,6 +8284,238 @@ version = "1.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
 
+[[package]]
+name = "llrt_abort"
+version = "0.9.0-beta"
+source = "git+https://github.com/awslabs/llrt?rev=7b95c82a9b15e7ddfb2778eca4b5a63111e74f51#7b95c82a9b15e7ddfb2778eca4b5a63111e74f51"
+dependencies = [
+ "llrt_async_hooks",
+ "llrt_events",
+ "llrt_exceptions",
+ "llrt_timers",
+ "llrt_utils",
+ "rquickjs",
+]
+
+[[package]]
+name = "llrt_async_hooks"
+version = "0.9.0-beta"
+source = "git+https://github.com/awslabs/llrt?rev=7b95c82a9b15e7ddfb2778eca4b5a63111e74f51#7b95c82a9b15e7ddfb2778eca4b5a63111e74f51"
+dependencies = [
+ "llrt_hooking",
+ "llrt_utils",
+ "rquickjs",
+ "tracing",
+]
+
+[[package]]
+name = "llrt_buffer"
+version = "0.9.0-beta"
+source = "git+https://github.com/awslabs/llrt?rev=7b95c82a9b15e7ddfb2778eca4b5a63111e74f51#7b95c82a9b15e7ddfb2778eca4b5a63111e74f51"
+dependencies = [
+ "itoa",
+ "llrt_encoding",
+ "llrt_stream_web",
+ "llrt_utils",
+ "rquickjs",
+ "ryu",
+]
+
+[[package]]
+name = "llrt_build"
+version = "0.9.0-beta"
+source = "git+https://github.com/awslabs/llrt?rev=7b95c82a9b15e7ddfb2778eca4b5a63111e74f51#7b95c82a9b15e7ddfb2778eca4b5a63111e74f51"
+dependencies = [
+ "rustc_version",
+]
+
+[[package]]
+name = "llrt_compression"
+version = "0.9.0-beta"
+source = "git+https://github.com/awslabs/llrt?rev=7b95c82a9b15e7ddfb2778eca4b5a63111e74f51#7b95c82a9b15e7ddfb2778eca4b5a63111e74f51"
+dependencies = [
+ "brotli 8.0.4",
+ "flate2",
+ "zstd",
+]
+
+[[package]]
+name = "llrt_context"
+version = "0.9.0-beta"
+source = "git+https://github.com/awslabs/llrt?rev=7b95c82a9b15e7ddfb2778eca4b5a63111e74f51#7b95c82a9b15e7ddfb2778eca4b5a63111e74f51"
+dependencies = [
+ "llrt_build",
+ "llrt_utils",
+ "rquickjs",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "llrt_crypto"
+version = "0.9.0-beta"
+source = "git+https://github.com/awslabs/llrt?rev=7b95c82a9b15e7ddfb2778eca4b5a63111e74f51#7b95c82a9b15e7ddfb2778eca4b5a63111e74f51"
+dependencies = [
+ "aes 0.9.3",
+ "aes-gcm 0.11.1",
+ "aes-kw 0.3.1",
+ "cbc 0.2.1",
+ "const-oid 0.10.2",
+ "crc32c",
+ "crc32fast",
+ "ctr 0.10.1",
+ "der 0.8.2",
+ "ecdsa 0.17.0",
+ "ed25519-dalek",
+ "elliptic-curve 0.14.1",
+ "hkdf 0.13.0",
+ "hmac 0.13.0",
+ "llrt_buffer",
+ "llrt_context",
+ "llrt_encoding",
+ "llrt_exceptions",
+ "llrt_json",
+ "llrt_utils",
+ "md-5 0.11.0",
+ "once_cell",
+ "p256 0.14.0",
+ "p384 0.14.0",
+ "p521 0.14.0",
+ "pbkdf2 0.13.0",
+ "pkcs8 0.11.0",
+ "rand 0.10.2",
+ "rquickjs",
+ "rsa 0.10.0-rc.18",
+ "sha1 0.11.0",
+ "spki 0.8.0",
+ "x25519-dalek 3.0.0",
+]
+
+[[package]]
+name = "llrt_encoding"
+version = "0.9.0-beta"
+source = "git+https://github.com/awslabs/llrt?rev=7b95c82a9b15e7ddfb2778eca4b5a63111e74f51#7b95c82a9b15e7ddfb2778eca4b5a63111e74f51"
+dependencies = [
+ "base64-simd",
+ "hex-simd",
+ "llrt_build",
+ "memchr",
+ "phf 0.14.0",
+]
+
+[[package]]
+name = "llrt_events"
+version = "0.9.0-beta"
+source = "git+https://github.com/awslabs/llrt?rev=7b95c82a9b15e7ddfb2778eca4b5a63111e74f51#7b95c82a9b15e7ddfb2778eca4b5a63111e74f51"
+dependencies = [
+ "llrt_utils",
+ "rquickjs",
+ "tracing",
+]
+
+[[package]]
+name = "llrt_exceptions"
+version = "0.9.0-beta"
+source = "git+https://github.com/awslabs/llrt?rev=7b95c82a9b15e7ddfb2778eca4b5a63111e74f51#7b95c82a9b15e7ddfb2778eca4b5a63111e74f51"
+dependencies = [
+ "llrt_utils",
+ "rquickjs",
+]
+
+[[package]]
+name = "llrt_hooking"
+version = "0.9.0-beta"
+source = "git+https://github.com/awslabs/llrt?rev=7b95c82a9b15e7ddfb2778eca4b5a63111e74f51#7b95c82a9b15e7ddfb2778eca4b5a63111e74f51"
+dependencies = [
+ "llrt_utils",
+ "once_cell",
+ "rquickjs",
+]
+
+[[package]]
+name = "llrt_json"
+version = "0.9.0-beta"
+source = "git+https://github.com/awslabs/llrt?rev=7b95c82a9b15e7ddfb2778eca4b5a63111e74f51#7b95c82a9b15e7ddfb2778eca4b5a63111e74f51"
+dependencies = [
+ "itoa",
+ "llrt_build",
+ "llrt_utils",
+ "rquickjs",
+ "ryu",
+ "simd-json",
+]
+
+[[package]]
+name = "llrt_path"
+version = "0.9.0-beta"
+source = "git+https://github.com/awslabs/llrt?rev=7b95c82a9b15e7ddfb2778eca4b5a63111e74f51#7b95c82a9b15e7ddfb2778eca4b5a63111e74f51"
+dependencies = [
+ "llrt_utils",
+ "memchr",
+ "rquickjs",
+]
+
+[[package]]
+name = "llrt_stream_web"
+version = "0.9.0-beta"
+source = "git+https://github.com/awslabs/llrt?rev=7b95c82a9b15e7ddfb2778eca4b5a63111e74f51#7b95c82a9b15e7ddfb2778eca4b5a63111e74f51"
+dependencies = [
+ "llrt_abort",
+ "llrt_utils",
+ "rquickjs",
+]
+
+[[package]]
+name = "llrt_timers"
+version = "0.9.0-beta"
+source = "git+https://github.com/awslabs/llrt?rev=7b95c82a9b15e7ddfb2778eca4b5a63111e74f51#7b95c82a9b15e7ddfb2778eca4b5a63111e74f51"
+dependencies = [
+ "llrt_context",
+ "llrt_hooking",
+ "llrt_utils",
+ "once_cell",
+ "rquickjs",
+ "tokio",
+]
+
+[[package]]
+name = "llrt_url"
+version = "0.9.0-beta"
+source = "git+https://github.com/awslabs/llrt?rev=7b95c82a9b15e7ddfb2778eca4b5a63111e74f51#7b95c82a9b15e7ddfb2778eca4b5a63111e74f51"
+dependencies = [
+ "llrt_utils",
+ "rquickjs",
+ "url",
+]
+
+[[package]]
+name = "llrt_utils"
+version = "0.9.0-beta"
+source = "git+https://github.com/awslabs/llrt?rev=7b95c82a9b15e7ddfb2778eca4b5a63111e74f51#7b95c82a9b15e7ddfb2778eca4b5a63111e74f51"
+dependencies = [
+ "half",
+ "libc",
+ "llrt_build",
+ "memchr",
+ "rquickjs",
+ "simdutf8",
+ "tokio",
+ "tracing",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "llrt_zlib"
+version = "0.9.0-beta"
+source = "git+https://github.com/awslabs/llrt?rev=7b95c82a9b15e7ddfb2778eca4b5a63111e74f51#7b95c82a9b15e7ddfb2778eca4b5a63111e74f51"
+dependencies = [
+ "llrt_buffer",
+ "llrt_compression",
+ "llrt_context",
+ "llrt_utils",
+ "rquickjs",
+]
+
 [[package]]
 name = "lock_api"
 version = "0.4.14"
@@ -8479,6 +9117,23 @@ dependencies = [
  "uuid",
 ]
 
+[[package]]
+name = "maka-desktop-shell"
+version = "0.2.0"
+dependencies = [
+ "gpui-shell",
+ "hex",
+ "maka-capabilities",
+ "maka-host-runtime",
+ "maka-plugin-core",
+ "maka-ui-runtime",
+ "serde_json",
+ "sha2 0.11.1",
+ "tokio",
+ "tokio-util",
+ "uuid",
+]
+
 [[package]]
 name = "maka-event-log"
 version = "0.2.0"
@@ -10322,6 +10977,16 @@ dependencies = [
  "objc2-core-foundation",
 ]
 
+[[package]]
+name = "objc2-io-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
+dependencies = [
+ "libc",
+ "objc2-core-foundation",
+]
+
 [[package]]
 name = "objc2-io-surface"
 version = "0.3.2"
@@ -10443,6 +11108,18 @@ dependencies = [
  "objc",
 ]
 
+[[package]]
+name = "object"
+version = "0.36.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
+dependencies = [
+ "crc32fast",
+ "hashbrown 0.15.5",
+ "indexmap 2.14.2",
+ "memchr",
+]
+
 [[package]]
 name = "object"
 version = "0.37.3"
@@ -10460,7 +11137,7 @@ checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb"
 dependencies = [
  "aead 0.5.2",
  "cipher 0.4.4",
- "ctr",
+ "ctr 0.9.2",
  "subtle",
 ]
 
@@ -10507,7 +11184,7 @@ dependencies = [
  "md-5 0.10.6",
  "num",
  "num-bigint-dig 0.9.1",
- "pbkdf2",
+ "pbkdf2 0.12.2",
  "serde",
  "serde_bytes",
  "sha2 0.10.9",
@@ -10710,24 +11387,51 @@ version = "0.13.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
 dependencies = [
- "ecdsa",
+ "ecdsa 0.16.9",
  "elliptic-curve 0.13.8",
- "primeorder",
+ "primeorder 0.13.6",
  "sha2 0.10.9",
 ]
 
+[[package]]
+name = "p256"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a"
+dependencies = [
+ "ecdsa 0.17.0",
+ "elliptic-curve 0.14.1",
+ "primefield",
+ "primeorder 0.14.0",
+ "sha2 0.11.1",
+]
+
 [[package]]
 name = "p384"
 version = "0.13.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
 dependencies = [
- "ecdsa",
+ "ecdsa 0.16.9",
  "elliptic-curve 0.13.8",
- "primeorder",
+ "primeorder 0.13.6",
  "sha2 0.10.9",
 ]
 
+[[package]]
+name = "p384"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f"
+dependencies = [
+ "ecdsa 0.17.0",
+ "elliptic-curve 0.14.1",
+ "fiat-crypto 0.3.0",
+ "primefield",
+ "primeorder 0.14.0",
+ "sha2 0.11.1",
+]
+
 [[package]]
 name = "p521"
 version = "0.13.3"
@@ -10735,13 +11439,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2"
 dependencies = [
  "base16ct 0.2.0",
- "ecdsa",
+ "ecdsa 0.16.9",
  "elliptic-curve 0.13.8",
- "primeorder",
+ "primeorder 0.13.6",
  "rand_core 0.6.4",
  "sha2 0.10.9",
 ]
 
+[[package]]
+name = "p521"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ad64cc32c2dc466317c12ee5853e61f159f9eab1fe7efade0395dc2e7b43449"
+dependencies = [
+ "base16ct 1.0.0",
+ "ecdsa 0.17.0",
+ "elliptic-curve 0.14.1",
+ "primefield",
+ "primeorder 0.14.0",
+ "sha2 0.11.1",
+]
+
 [[package]]
 name = "palette"
 version = "0.7.7"
@@ -10854,6 +11572,15 @@ dependencies = [
  "hmac 0.12.1",
 ]
 
+[[package]]
+name = "pbkdf2"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629"
+dependencies = [
+ "digest 0.11.3",
+]
+
 [[package]]
 name = "pem-rfc7468"
 version = "0.7.0"
@@ -10863,6 +11590,15 @@ dependencies = [
  "base64ct",
 ]
 
+[[package]]
+name = "pem-rfc7468"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9"
+dependencies = [
+ "base64ct",
+]
+
 [[package]]
 name = "percent-encoding"
 version = "2.3.2"
@@ -10914,11 +11650,22 @@ version = "0.13.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
 dependencies = [
- "phf_macros",
+ "phf_macros 0.13.1",
  "phf_shared 0.13.1",
  "serde",
 ]
 
+[[package]]
+name = "phf"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
+dependencies = [
+ "phf_macros 0.14.0",
+ "phf_shared 0.14.0",
+ "serde",
+]
+
 [[package]]
 name = "phf_codegen"
 version = "0.11.3"
@@ -10959,6 +11706,16 @@ dependencies = [
  "phf_shared 0.13.1",
 ]
 
+[[package]]
+name = "phf_generator"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
+dependencies = [
+ "fastrand",
+ "phf_shared 0.14.0",
+]
+
 [[package]]
 name = "phf_macros"
 version = "0.13.1"
@@ -10972,6 +11729,19 @@ dependencies = [
  "syn 2.0.119",
 ]
 
+[[package]]
+name = "phf_macros"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085"
+dependencies = [
+ "phf_generator 0.14.0",
+ "phf_shared 0.14.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
 [[package]]
 name = "phf_shared"
 version = "0.11.3"
@@ -10999,6 +11769,15 @@ dependencies = [
  "siphasher",
 ]
 
+[[package]]
+name = "phf_shared"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89"
+dependencies = [
+ "siphasher",
+]
+
 [[package]]
 name = "pico-args"
 version = "0.5.0"
@@ -11061,6 +11840,16 @@ dependencies = [
  "spki 0.7.3",
 ]
 
+[[package]]
+name = "pkcs1"
+version = "0.8.0-rc.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e"
+dependencies = [
+ "der 0.8.2",
+ "spki 0.8.0",
+]
+
 [[package]]
 name = "pkcs8"
 version = "0.10.2"
@@ -11287,6 +12076,17 @@ dependencies = [
  "universal-hash 0.5.1",
 ]
 
+[[package]]
+name = "polyval"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd"
+dependencies = [
+ "cpubits",
+ "cpufeatures 0.3.1",
+ "universal-hash 0.6.1",
+]
+
 [[package]]
 name = "portable-atomic"
 version = "1.15.0"
@@ -11378,6 +12178,20 @@ dependencies = [
  "syn 2.0.119",
 ]
 
+[[package]]
+name = "primefield"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4"
+dependencies = [
+ "crypto-bigint 0.7.5",
+ "crypto-common 0.2.2",
+ "ff 0.14.0",
+ "rand_core 0.10.1",
+ "subtle",
+ "zeroize",
+]
+
 [[package]]
 name = "primeorder"
 version = "0.13.6"
@@ -11387,6 +12201,17 @@ dependencies = [
  "elliptic-curve 0.13.8",
 ]
 
+[[package]]
+name = "primeorder"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06"
+dependencies = [
+ "elliptic-curve 0.14.1",
+ "primefield",
+ "wnaf",
+]
+
 [[package]]
 name = "proc-macro-crate"
 version = "3.5.0"
@@ -11578,6 +12403,98 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "quickjs-jit"
+version = "0.12.11"
+source = "git+https://github.com/longbridge/quickjs-jit?rev=96593cd7a54440ac060a391b8baea7e9a3cf1393#96593cd7a54440ac060a391b8baea7e9a3cf1393"
+dependencies = [
+ "either",
+ "indexmap 2.14.2",
+ "quickjs-jit-core",
+ "quickjs-jit-macro",
+]
+
+[[package]]
+name = "quickjs-jit-core"
+version = "0.12.11"
+source = "git+https://github.com/longbridge/quickjs-jit?rev=96593cd7a54440ac060a391b8baea7e9a3cf1393#96593cd7a54440ac060a391b8baea7e9a3cf1393"
+dependencies = [
+ "async-lock",
+ "bytes",
+ "chrono",
+ "dlopen2 0.9.0",
+ "either",
+ "half",
+ "hashbrown 0.17.1",
+ "indexmap 2.14.2",
+ "phf 0.14.0",
+ "quickjs-jit-sys",
+ "relative-path",
+]
+
+[[package]]
+name = "quickjs-jit-macro"
+version = "0.12.11"
+source = "git+https://github.com/longbridge/quickjs-jit?rev=96593cd7a54440ac060a391b8baea7e9a3cf1393#96593cd7a54440ac060a391b8baea7e9a3cf1393"
+dependencies = [
+ "convert_case 0.11.0",
+ "fnv",
+ "ident_case",
+ "indexmap 2.14.2",
+ "phf_generator 0.14.0",
+ "phf_shared 0.14.0",
+ "proc-macro-crate",
+ "proc-macro2",
+ "quickjs-jit-core",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "quickjs-jit-runtime"
+version = "0.12.11"
+source = "git+https://github.com/longbridge/quickjs-jit?rev=96593cd7a54440ac060a391b8baea7e9a3cf1393#96593cd7a54440ac060a391b8baea7e9a3cf1393"
+dependencies = [
+ "cranelift-codegen",
+ "cranelift-frontend",
+ "cranelift-module",
+ "cranelift-native",
+ "cranelift-object",
+ "gimli 0.31.1",
+ "libc",
+ "postcard",
+ "quickjs-jit-core",
+ "quickjs-jit-sys",
+ "rustc-hash 2.1.3",
+ "serde",
+ "serde_json",
+ "serde_yaml",
+ "target-lexicon",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "quickjs-jit-stdlib"
+version = "0.12.7"
+source = "git+https://github.com/longbridge/quickjs-jit?rev=605da483611a3548edb8c33fdff602e3f5f42076#605da483611a3548edb8c33fdff602e3f5f42076"
+dependencies = [
+ "llrt_buffer",
+ "llrt_crypto",
+ "llrt_path",
+ "llrt_url",
+ "llrt_zlib",
+ "rquickjs",
+]
+
+[[package]]
+name = "quickjs-jit-sys"
+version = "0.12.11"
+source = "git+https://github.com/longbridge/quickjs-jit?rev=96593cd7a54440ac060a391b8baea7e9a3cf1393#96593cd7a54440ac060a391b8baea7e9a3cf1393"
+dependencies = [
+ "bindgen 0.72.1",
+ "cc",
+]
+
 [[package]]
 name = "quinn"
 version = "0.11.11"
@@ -12044,6 +12961,21 @@ dependencies = [
  "serde_json",
 ]
 
+[[package]]
+name = "regalloc2"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc06e6b318142614e4a48bc725abbf08ff166694835c43c9dae5a9009704639a"
+dependencies = [
+ "allocator-api2",
+ "bumpalo",
+ "hashbrown 0.15.5",
+ "log",
+ "rustc-hash 2.1.3",
+ "serde",
+ "smallvec",
+]
+
 [[package]]
 name = "regex"
 version = "1.13.1"
@@ -12116,6 +13048,15 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "relative-path"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2a073568cd8b9c4825429d3e9fc5f52851b4f97cc1348800689408921e6b8f8"
+dependencies = [
+ "serde",
+]
+
 [[package]]
 name = "rend"
 version = "0.4.2"
@@ -12231,6 +13172,16 @@ dependencies = [
  "subtle",
 ]
 
+[[package]]
+name = "rfc6979"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b"
+dependencies = [
+ "crypto-bigint 0.7.5",
+ "hmac 0.13.0",
+]
+
 [[package]]
 name = "rgb"
 version = "0.8.53"
@@ -12307,6 +13258,13 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "rquickjs"
+version = "0.12.9"
+dependencies = [
+ "quickjs-jit",
+]
+
 [[package]]
 name = "rsa"
 version = "0.9.10"
@@ -12318,7 +13276,7 @@ dependencies = [
  "num-bigint-dig 0.8.6",
  "num-integer",
  "num-traits",
- "pkcs1",
+ "pkcs1 0.7.5",
  "pkcs8 0.10.2",
  "rand_core 0.6.4",
  "signature 2.2.0",
@@ -12327,6 +13285,25 @@ dependencies = [
  "zeroize",
 ]
 
+[[package]]
+name = "rsa"
+version = "0.10.0-rc.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf"
+dependencies = [
+ "const-oid 0.10.2",
+ "crypto-bigint 0.7.5",
+ "crypto-primes",
+ "digest 0.11.3",
+ "pkcs1 0.8.0-rc.4",
+ "pkcs8 0.11.0",
+ "rand_core 0.10.1",
+ "sha2 0.11.1",
+ "signature 3.0.0",
+ "spki 0.8.0",
+ "zeroize",
+]
+
 [[package]]
 name = "rsqlite-vfs"
 version = "0.1.1"
@@ -13372,6 +14349,18 @@ version = "0.3.10"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
 
+[[package]]
+name = "simd-json"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ebe141eb4a23ecc4d5de93fa5b139ac65dc07c38037a2fe9a8fb7c9d36bd832"
+dependencies = [
+ "halfbrown",
+ "ref-cast",
+ "simdutf8",
+ "value-trait",
+]
+
 [[package]]
 name = "simd_cesu8"
 version = "1.2.0"
@@ -13810,7 +14799,7 @@ version = "0.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
 dependencies = [
- "float-cmp",
+ "float-cmp 0.9.0",
 ]
 
 [[package]]
@@ -14110,6 +15099,20 @@ dependencies = [
  "windows 0.57.0",
 ]
 
+[[package]]
+name = "sysinfo"
+version = "0.37.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f"
+dependencies = [
+ "libc",
+ "memchr",
+ "ntapi",
+ "objc2-core-foundation",
+ "objc2-io-kit",
+ "windows 0.61.3",
+]
+
 [[package]]
 name = "system-configuration"
 version = "0.6.1"
@@ -14189,6 +15192,12 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "target-lexicon"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
+
 [[package]]
 name = "tauri-winrt-notification"
 version = "0.8.1"
@@ -14871,8 +15880,12 @@ dependencies = [
  "httparse",
  "log",
  "rand 0.9.5",
+ "rustls",
+ "rustls-pki-types",
  "sha1 0.10.7",
  "thiserror 2.0.20",
+ "url",
+ "webpki-roots 0.26.11",
 ]
 
 [[package]]
@@ -15342,6 +16355,18 @@ version = "0.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
 
+[[package]]
+name = "value-trait"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3f4b4a98dfe54bc9ed3641af7ffcb837240269627dbd5cb047d13daa39736cc"
+dependencies = [
+ "float-cmp 0.10.0",
+ "halfbrown",
+ "itoa",
+ "ryu",
+]
+
 [[package]]
 name = "vcpkg"
 version = "0.2.15"
@@ -16716,6 +17741,18 @@ dependencies = [
  "wayland-protocols-wlr",
 ]
 
+[[package]]
+name = "wnaf"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa"
+dependencies = [
+ "ff 0.14.0",
+ "group 0.14.0",
+ "hybrid-array",
+ "primefield",
+]
+
 [[package]]
 name = "writeable"
 version = "0.6.4"
@@ -16767,12 +17804,23 @@ version = "2.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
 dependencies = [
- "curve25519-dalek",
+ "curve25519-dalek 4.1.3",
  "rand_core 0.6.4",
  "serde",
  "zeroize",
 ]
 
+[[package]]
+name = "x25519-dalek"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6"
+dependencies = [
+ "curve25519-dalek 5.0.0",
+ "rand_core 0.10.1",
+ "zeroize",
+]
+
 [[package]]
 name = "x509-parser"
 version = "0.15.1"
@@ -17080,7 +18128,7 @@ dependencies = [
  "rand 0.8.5",
  "screencapturekit 0.2.8",
  "screencapturekit-sys",
- "sysinfo",
+ "sysinfo 0.31.4",
  "tao-core-video-sys",
  "windows 0.61.3",
  "windows-capture",
diff --git a/Cargo.toml b/Cargo.toml
index a7de2164f9..8558bcaad6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -32,6 +32,8 @@ members = [
     "plugins/workspace/workspace-files/native",
     "plugins/workspace/support",
     "third-party/gpui-kit/crates/base",
+    "third-party/gpui-kit/crates/shell",
+    "third-party/gpui-kit/crates/shell/rquickjs-compat",
     "products/maka/acp",
     "plugins/agent/engine",
     "plugins/agent/native",
@@ -42,6 +44,7 @@ members = [
     "products/maka/cli",
     "plugins/settings/store",
     "products/maka/desktop",
+    "products/maka/desktop-shell",
     "platform/computer-use",
     "platform/event-log",
     "plugins/agent/acp-executor/engine",
@@ -109,6 +112,7 @@ maka-agent-authority = { path = "plugins/agent/authority" }
 maka-web-api = { path = "plugins/workspace/workspace-web/api" }
 maka-agent-api = { path = "plugins/agent/api" }
 gpui-kit = { git = "https://github.com/longbridge/gpui-kit", rev = "c5f8eca4a0759f8c0c7ed624eddfd03f1c93a5f2", default-features = false, features = ["gpui-fast"] }
+gpui-shell = { path = "third-party/gpui-kit/crates/shell", default-features = false, features = ["gpui-fast", "quickjs"] }
 maka-transport = { path = "platform/transport" }
 cua-driver-sdk = { git = "https://github.com/maka-agent/cua", rev = "79c9b58a1b61f66ae966635c464e54cb2c8151d6" }
 cua-driver-contract = { git = "https://github.com/maka-agent/cua", rev = "79c9b58a1b61f66ae966635c464e54cb2c8151d6" }
@@ -195,6 +199,8 @@ opt-level = 0
 image-webp = { path = "third-party/image-webp" }
 sqlx-sqlite = { path = "third-party/sqlx-sqlite" }
 async-io = { path = "third-party/async-io" }
+# gpui-shell and LLRT share one quickjs-jit VM through this facade, as in gpui-kit.
+rquickjs = { path = "third-party/gpui-kit/crates/shell/rquickjs-compat" }
 
 gpui-fast = { path = "third-party/gpui-fast/crates/gpui" }
 gpui-fast-macos = { path = "third-party/gpui-fast/crates/gpui_macos" }
diff --git a/biome.jsonc b/biome.jsonc
index e21d198483..a2cbc15c2d 100644
--- a/biome.jsonc
+++ b/biome.jsonc
@@ -36,7 +36,10 @@
       "!website/**",
       // Verbatim Deno sources; tests verify their exact bytes.
       "!platform/js-runtime/third-party/deno-telemetry/telemetry.ts",
-      "!platform/js-runtime/third-party/deno-telemetry/util.ts"
+      "!platform/js-runtime/third-party/deno-telemetry/util.ts",
+      // Verbatim gpui-kit source; third-party/gpui-kit/upstream.json records
+      // its hash.
+      "!third-party/gpui-kit/crates/shell/component-inventory.json"
     ]
   },
   // Formatter enabled together with the one-time whole-tree baseline commit
diff --git a/products/maka/cli/THIRD_PARTY_NOTICES.txt b/products/maka/cli/THIRD_PARTY_NOTICES.txt
index 79b71ac609..2661cbcd7d 100644
--- a/products/maka/cli/THIRD_PARTY_NOTICES.txt
+++ b/products/maka/cli/THIRD_PARTY_NOTICES.txt
@@ -1009,6 +1009,7 @@ hashbrown@0.14.5 (MIT OR Apache-2.0); authors declared by package: Amanieu d'Ant
 hashbrown@0.15.5 (MIT OR Apache-2.0); authors declared by package: Amanieu d'Antras <am...@gmail.com>
 hashbrown@0.16.1 (MIT OR Apache-2.0); authors declared by package: Amanieu d'Antras <am...@gmail.com>
 hashbrown@0.17.1 (MIT OR Apache-2.0)
+hdrhistogram@7.6.0 (MIT/Apache-2.0); authors declared by package: Jon Gjengset <jo...@thesquareplanet.com>, Marshall Pierce <ma...@mpierce.org>
 heapless@0.7.17 (MIT OR Apache-2.0); authors declared by package: Jorge Aparicio <jo...@japaric.io>, Per Lindgren <pe...@ltu.se>, Emil Fresk <em...@gmail.com>
 heapless@0.8.0 (MIT OR Apache-2.0); authors declared by package: Jorge Aparicio <jo...@japaric.io>, Per Lindgren <pe...@ltu.se>, Emil Fresk <em...@gmail.com>
 heapless@0.9.3 (MIT OR Apache-2.0); authors declared by package: Jorge Aparicio <jo...@japaric.io>, Per Lindgren <pe...@ltu.se>, Emil Fresk <em...@gmail.com>
@@ -1283,6 +1284,7 @@ https://crates.io/api/v1/crates/hashbrown/0.14.5/download :: LICENSE-APACHE
 https://crates.io/api/v1/crates/hashbrown/0.15.5/download :: LICENSE-APACHE
 https://crates.io/api/v1/crates/hashbrown/0.16.1/download :: LICENSE-APACHE
 https://crates.io/api/v1/crates/hashbrown/0.17.1/download :: LICENSE-APACHE
+https://crates.io/api/v1/crates/hdrhistogram/7.6.0/download :: LICENSE-APACHE
 https://crates.io/api/v1/crates/heapless/0.7.17/download :: LICENSE-APACHE
 https://crates.io/api/v1/crates/heapless/0.8.0/download :: LICENSE-APACHE
 https://crates.io/api/v1/crates/heapless/0.9.3/download :: LICENSE-APACHE
@@ -1956,6 +1958,7 @@ crypto-common@0.2.2 (MIT OR Apache-2.0); authors declared by package: RustCrypto
 ctr@0.9.2 (MIT OR Apache-2.0); authors declared by package: RustCrypto Developers
 der@0.7.10 (Apache-2.0 OR MIT); authors declared by package: RustCrypto Developers
 der@0.8.2 (Apache-2.0 OR MIT); authors declared by package: RustCrypto Developers
+der_derive@0.8.0 (Apache-2.0 OR MIT); authors declared by package: RustCrypto Developers
 digest@0.10.7 (MIT OR Apache-2.0); authors declared by package: RustCrypto Developers
 digest@0.11.3 (MIT OR Apache-2.0); authors declared by package: RustCrypto Developers
 ed448-goldilocks@0.14.0-pre.15 (Apache-2.0 OR MIT); authors declared by package: RustCrypto Developers
@@ -1984,6 +1987,7 @@ p521@0.13.3 (Apache-2.0 OR MIT); authors declared by package: RustCrypto Develop
 password-hash@0.6.1 (MIT OR Apache-2.0); authors declared by package: RustCrypto Developers
 pbkdf2@0.12.2 (MIT OR Apache-2.0); authors declared by package: RustCrypto Developers
 pem-rfc7468@0.7.0 (Apache-2.0 OR MIT); authors declared by package: RustCrypto Developers
+pem-rfc7468@1.0.0 (Apache-2.0 OR MIT); authors declared by package: RustCrypto Developers
 phc@0.6.1 (Apache-2.0 OR MIT); authors declared by package: RustCrypto Developers
 pkcs1@0.7.5 (Apache-2.0 OR MIT); authors declared by package: RustCrypto Developers
 pkcs8@0.10.2 (Apache-2.0 OR MIT); authors declared by package: RustCrypto Developers
@@ -2041,6 +2045,7 @@ https://crates.io/api/v1/crates/crypto-common/0.2.2/download :: LICENSE-APACHE
 https://crates.io/api/v1/crates/ctr/0.9.2/download :: LICENSE-APACHE
 https://crates.io/api/v1/crates/der/0.7.10/download :: LICENSE-APACHE
 https://crates.io/api/v1/crates/der/0.8.2/download :: LICENSE-APACHE
+https://crates.io/api/v1/crates/der_derive/0.8.0/download :: LICENSE-APACHE
 https://crates.io/api/v1/crates/digest/0.10.7/download :: LICENSE-APACHE
 https://crates.io/api/v1/crates/digest/0.11.3/download :: LICENSE-APACHE
 https://crates.io/api/v1/crates/ed448-goldilocks/0.14.0-pre.15/download :: LICENSE-APACHE
@@ -2069,6 +2074,7 @@ https://crates.io/api/v1/crates/p521/0.13.3/download :: LICENSE-APACHE
 https://crates.io/api/v1/crates/password-hash/0.6.1/download :: LICENSE-APACHE
 https://crates.io/api/v1/crates/pbkdf2/0.12.2/download :: LICENSE-APACHE
 https://crates.io/api/v1/crates/pem-rfc7468/0.7.0/download :: LICENSE-APACHE
+https://crates.io/api/v1/crates/pem-rfc7468/1.0.0/download :: LICENSE-APACHE
 https://crates.io/api/v1/crates/phc/0.6.1/download :: LICENSE-APACHE
 https://crates.io/api/v1/crates/pkcs1/0.7.5/download :: LICENSE-APACHE
 https://crates.io/api/v1/crates/pkcs8/0.10.2/download :: LICENSE-APACHE
@@ -9962,8 +9968,10 @@ DEALINGS IN THE SOFTWARE.
 ================================================================================
 
 cpufeatures@0.2.17 (MIT OR Apache-2.0); authors declared by package: RustCrypto Developers
+der_derive@0.8.0 (Apache-2.0 OR MIT); authors declared by package: RustCrypto Developers
 
 https://crates.io/api/v1/crates/cpufeatures/0.2.17/download :: LICENSE-MIT
+https://crates.io/api/v1/crates/der_derive/0.8.0/download :: LICENSE-MIT
 
 Copyright (c) 2020-2025 The RustCrypto Project Developers
 
@@ -18993,6 +19001,34 @@ DEALINGS IN THE SOFTWARE.
 
 ================================================================================
 
+hdrhistogram@7.6.0 (MIT/Apache-2.0); authors declared by package: Jon Gjengset <jo...@thesquareplanet.com>, Marshall Pierce <ma...@mpierce.org>
+
+https://crates.io/api/v1/crates/hdrhistogram/7.6.0/download :: LICENSE-MIT
+
+The MIT License (MIT)
+
+Copyright (c) 2016 Jon Gjengset
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+================================================================================
+
 hex@0.4.3 (MIT OR Apache-2.0); authors declared by package: KokaKiwi <ko...@kokakiwi.net>
 
 https://crates.io/api/v1/crates/hex/0.4.3/download :: LICENSE-MIT
@@ -26756,6 +26792,38 @@ DEALINGS IN THE SOFTWARE.
 
 ================================================================================
 
+pem-rfc7468@1.0.0 (Apache-2.0 OR MIT); authors declared by package: RustCrypto Developers
+
+https://crates.io/api/v1/crates/pem-rfc7468/1.0.0/download :: LICENSE-MIT
+
+Copyright (c) 2021-2025 The RustCrypto Project Developers
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+================================================================================
+
 petgraph@0.8.3 (MIT OR Apache-2.0); authors declared by package: bluss, mitchmindtree
 
 https://crates.io/api/v1/crates/petgraph/0.8.3/download :: assets/images/LICENSE.md
diff --git a/products/maka/cli/notices.json b/products/maka/cli/notices.json
index 418ce075d5..49911f8617 100644
--- a/products/maka/cli/notices.json
+++ b/products/maka/cli/notices.json
@@ -1,7 +1,7 @@
 {
   "inputs": {
-    "Cargo.lock": "25874a9b8b53e14fb9f3720dad54b3c3bb56601afb747ba7fbb1b9de49c175f4",
-    "Cargo.toml": "9a2a29f608172df146deb8fea723605a6c66c3ca7af4871fffc6cc2509f3cefb",
+    "Cargo.lock": "6f74758c4ff36bdbc81ffc86ab621ebee65c3a8189b395dbad63d27dcfda0c3c",
+    "Cargo.toml": "14267cde6fe41af5ecb119508050e0d32044b91f9dbb41544681747eae35e9be",
     "package-lock.json": "a5af15aca21fabe0e1bdd48784167df4dc81cee17777d54861dcbd641168a4eb",
     "package.json": "38baf3beeb5876849ca16e31868d59bfaee769f260c7555b4a8a1847b92e28a1",
     "packages/plugin-sdk/src/configurations.ts": "20e587b47e84a31fc794176f85e7ac71c1b8b7f88a46947f9403011c307fff1e",
@@ -438,6 +438,7 @@
     "products/maka/cli/Cargo.toml": "2a41d9dd434d08bb62cfe784e32b9e9facd41c58bbe9eb1b809b3fde3a2ed446",
     "products/maka/cli/build.rs": "852960c81f7f6ee03e882eabfa07603bcb8727b98f22a3e6e2552e6e375668cd",
     "products/maka/cli/build/product.rs": "6302eefa7c818dbd73e75f7796d331542315b79032a62727fa43f53413c25766",
+    "products/maka/desktop-shell/Cargo.toml": "3b5d668dea0bbdec2925a7449fd7f5a5cd7703cf0783e4cd9a74bd9800c5ac75",
     "products/maka/desktop/Cargo.toml": "6c1bdac5c0eb0d3f205005af94837fac3abb904441d9e8fa2096469ab606ac8e",
     "products/maka/native/Cargo.toml": "4a3c69dfe3d7e5773316dbd0f703ec376cbaef0a7b9915fce395b8460c633d76",
     "scripts/apply-dependency-patches.mjs": "67e548835b0de28e3753be559abb5cb2efbe94a46d72e0b756c23f944896d4bd",
@@ -480,8 +481,8 @@
     "third-party/gpui-fast/Cargo.toml": "4d549553fd2df7aba644dae5f978857c5ba22900d78d32bac905b8d75651b6c8",
     "third-party/gpui-fast/LICENSE": "5f78a733701db49b73da93addad8cb74f816f3789ef30c3c0e959e943ecd99b4",
     "third-party/gpui-fast/LICENSE-APACHE": "5f78a733701db49b73da93addad8cb74f816f3789ef30c3c0e959e943ecd99b4",
-    "third-party/gpui-fast/SOURCE.md": "8f17a2dff9e1ff205add9f88863ccce3347fb27e65f948970113ad967aac33ba",
-    "third-party/gpui-fast/SOURCE.zh-CN.md": "b9b07a41d601aa4a940c0ecb45031e0edc6c74b8ec72628e0b8e9a484325736c",
+    "third-party/gpui-fast/SOURCE.md": "e4a930998288632d34f6af4be837065cae8b00eef96245707685038d28b7f824",
+    "third-party/gpui-fast/SOURCE.zh-CN.md": "e4092ff9b70829c8085729d0722cc1bf8ebfae72296cf9477387a439168be804",
     "third-party/gpui-fast/assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf": "975dcda37d80f038dcd143c22e33ca2d97a0cc5a929aace1c749153b0fe1afa5",
     "third-party/gpui-fast/assets/fonts/ibm-plex-sans/license.txt": "91c25c350d3cac39da2736d74f7ba37ef648f5237a4e330a240615bc8d8c4360",
     "third-party/gpui-fast/assets/fonts/lilex/Lilex-Regular.ttf": "8a467ad5a1941dc7c9cebaee34e3ed9364a73b48393034e363e4b528bd711d04",
@@ -543,7 +544,7 @@
     "third-party/gpui-fast/crates/gpui/src/fast/layers/hover_freeze.rs": "249d6048dbaeae018e741358815b6b272c6868dee73d5decb553080cf4675981",
     "third-party/gpui-fast/crates/gpui/src/fast/layers/input.rs": "a88ec16517f5fd0b9bcf1d3158e9475b449e9cbb201a3c393d64fa1b448e4bb2",
     "third-party/gpui-fast/crates/gpui/src/fast/layers/invalidate.rs": "cdca05c90d5acaaf99e27acb36e19d3f0ca03c30b26c92007d49cba20975dff0",
-    "third-party/gpui-fast/crates/gpui/src/fast/layers/lists.rs": "cdf53137b82e1ad9cdf17d6b62ba6efc61df67ac5e5763c9480396c58db9fef1",
+    "third-party/gpui-fast/crates/gpui/src/fast/layers/lists.rs": "46f52a92efadb3d05e18a075e73d0efbe970d16e74cb62d360641c44a4f1e5b5",
     "third-party/gpui-fast/crates/gpui/src/fast/layers/mod.rs": "e4e72047559318b36beadff4514530463a767e6c8113231c32a0569121ba8f63",
     "third-party/gpui-fast/crates/gpui/src/fast/layers/overlay.rs": "63031bca6d31064880514b63c07607431a87fb38dedd5aba0468b4b9b7cd6b5c",
     "third-party/gpui-fast/crates/gpui/src/fast/layers/paint.rs": "1887ca85af79cb1b8d3e55e386666c2aa558b23d0ce8616f2b5504fe322ca5dd",
@@ -556,7 +557,7 @@
     "third-party/gpui-fast/crates/gpui/src/fast/layers/verify.rs": "ee186c02b95a622154846538c2dab41de6e6ce7bfae1056f4d155be2304a004e",
     "third-party/gpui-fast/crates/gpui/src/fast/layers/wheel.rs": "ebfcaba6fb3c95f7d1660710c2a266a4a7c9af63f0ada4a0e6ef892c6c91c0ce",
     "third-party/gpui-fast/crates/gpui/src/fast/layers/work.rs": "3854efa6e74d677897844d3b454612a25963d70f150deb8e2af943fe98e3f205",
-    "third-party/gpui-fast/crates/gpui/src/fast/layout.rs": "c5ef3743baa472c41e8cc7500f1eb13960decaf5dacaf5558b82d5b8a26a2871",
+    "third-party/gpui-fast/crates/gpui/src/fast/layout.rs": "5591f12968a9424b4ff1444dac95951e9ae1825e7f0f755f2a4a3e7b9de6b340",
     "third-party/gpui-fast/crates/gpui/src/fast/layout_bounds.rs": "6a76a1cb904e315c4a280c7630a4e43a591fe3f2fdce55709915a5a6df70eda0",
     "third-party/gpui-fast/crates/gpui/src/fast/layout_key.rs": "207bc5f474a48046e264eb0a88726d099e450cbd3694ec5e83dea642f114d048",
     "third-party/gpui-fast/crates/gpui/src/fast/line_break.rs": "a136de66c81ebeb3304916b4ec664762f4ed18da114f7009f61617b4ec90a8aa",
@@ -575,7 +576,7 @@
     "third-party/gpui-fast/crates/gpui/src/fast/tests/dispatch.rs": "80e60f28d65b094dc0a6ec14b86b2fa77022310382164c6553b6a861fa1c0529",
     "third-party/gpui-fast/crates/gpui/src/fast/tests/global_id.rs": "e114b24de4a1a82c6aa16537e8783e2a493b1f6a4cf3d45e8df9c3d5e88608db",
     "third-party/gpui-fast/crates/gpui/src/fast/tests/layers.rs": "d6bb89977d26c053b2ab3af6497877284ab9718f7cd9c6e92288bc3e30b4319a",
-    "third-party/gpui-fast/crates/gpui/src/fast/tests/layers_lists.rs": "a6f45ce4f8e3867af006692fa67db1dcd912895931d6ff1594994ac67a0471e6",
+    "third-party/gpui-fast/crates/gpui/src/fast/tests/layers_lists.rs": "9207a9e0d589ea09dcd2a8b683d03c38a5fac2721dbdf01870c8ebcf4da4d8ed",
     "third-party/gpui-fast/crates/gpui/src/fast/tests/layers_oracle.rs": "6c5883a4f26ff4238e9355d50e583938e213dd2508de64fb6508afbf9386c799",
     "third-party/gpui-fast/crates/gpui/src/fast/tests/layout.rs": "3dff17c6786f686c9c20610cdf8ce4e378dd262daa8dcbe9a1c78f3a8de04801",
     "third-party/gpui-fast/crates/gpui/src/fast/tests/misc/mod.rs": "d3edc13befd6096fd0735b791d1db2a24c079a1a1941bcec46a39a7e1d94cc12",
@@ -755,7 +756,7 @@
     "third-party/gpui-kit/LICENSE-APACHE": "d1b0449e5478c574ba4f686c2656df7fe77d66821a61f8b6ed3378a58ed9a811",
     "third-party/gpui-kit/LICENSE-DOCS.md": "8a9ccf144b2d5bf5aae56abdc6e723e17e1ba4de1f5d515b3b644288fd4fc16f",
     "third-party/gpui-kit/README.md": "7ed4da0c4b942614fcef5de14337879155c815d6ae51c36af1364634fb165a6c",
-    "third-party/gpui-kit/SOURCE.md": "07d530c68497ffe9ba97e4753cee1d9b3fa0128fa54cc1bd281b827933754174",
+    "third-party/gpui-kit/SOURCE.md": "521420d85722d3fb3e625432ecea2ed2468ac149c1262e33a527b1868d5318ac",
     "third-party/gpui-kit/crates/base/Cargo.toml": "715deda4da1f9d8ce5de6ead801da7428747e1900535ba333e90db73181a966b",
     "third-party/gpui-kit/crates/base/LICENSE-APACHE": "d1b0449e5478c574ba4f686c2656df7fe77d66821a61f8b6ed3378a58ed9a811",
     "third-party/gpui-kit/crates/base/README.md": "8c1f0ce117fd98117f6733ab4f8a1c07b9f5723f8623b60b988754c00cb40d61",
@@ -837,7 +838,7 @@
     "third-party/gpui-kit/crates/base/src/input/editor/display_map/mod.rs": "8b36946312de3c3f230d416c319c15dfaa5474570474daa71c06e7c5e0c898f8",
     "third-party/gpui-kit/crates/base/src/input/editor/display_map/text_wrapper.rs": "42cda09882b7dd849ad375e7f73b5499818815b3f6d3b15f7ef44df86ae2c54c",
     "third-party/gpui-kit/crates/base/src/input/editor/display_map/wrap_map.rs": "165edaceb27ca7eddaea1e7d94ddb022c7c83612b5da02b0a2b15cb6db7cad90",
-    "third-party/gpui-kit/crates/base/src/input/editor/highlighting.rs": "a8ddf22a7e48437da874e5f4f665b0826660a39ea364a564bfdf77e90f218155",
+    "third-party/gpui-kit/crates/base/src/input/editor/highlighting.rs": "a5b169967add0bed4efd2122d6c5b55e9b03c22ecb534a366f7be04c61e15383",
     "third-party/gpui-kit/crates/base/src/input/editor/indent.rs": "9de9771a9cbf8a2f5d2bd67b03641d29e8e7289c9739ec551ac67d79c090b772",
     "third-party/gpui-kit/crates/base/src/input/editor/language.rs": "4f95415cbc0f2a2fe9e5ac7a571f23daa0b08336f7bf355709d63607ad4d94c9",
     "third-party/gpui-kit/crates/base/src/input/editor/language_config.rs": "6fc15fe185b8a15d79e3dddf92f1bb3d9bd3fd482767a30c26ce0f5dd8c030f7",
@@ -928,8 +929,8 @@
     "third-party/gpui-kit/crates/base/src/tabs.rs": "e3fcdaeaa72f6d4b34e30aa54bfb0a872e92e32bd3d093382bff0fc3bf1583b8",
     "third-party/gpui-kit/crates/base/src/test_support.rs": "6be2a2fac0504aad624fce074d61c227d95e664b88384e7ed6fc11b9cab6ab5a",
     "third-party/gpui-kit/crates/base/src/text/document.rs": "cad6529dbb35e892c117c748b86198cfab37082427a81205619b140ec4df3ec2",
-    "third-party/gpui-kit/crates/base/src/text/format/html.rs": "4aa4b5183d14a101a220dfc12b7dbc8b3ec922e0fe50cd7eebdbd73a91f3e96f",
-    "third-party/gpui-kit/crates/base/src/text/format/html5minify/mod.rs": "5463894f70dd86d0d079c9454ab4ebf702bd32be3e4052bcc9f7dec81262b069",
+    "third-party/gpui-kit/crates/base/src/text/format/html.rs": "541954ff4cde6062bc243dd93c30ff0ae12f76fe83d3d19eeb73226edab8c08c",
+    "third-party/gpui-kit/crates/base/src/text/format/html5minify/mod.rs": "201963ab9c300088dda2c8db27f28d996fc670d34cb456c837ed4f3e603363e4",
     "third-party/gpui-kit/crates/base/src/text/format/markdown.rs": "55de3381b1e428d9f5ba5ee8bdbf8a7b496b9ba1a100d8cb741a4bc22587a68a",
     "third-party/gpui-kit/crates/base/src/text/format/mod.rs": "a9c352dd0d58f53f6dffe14568378bc481c8352985b2653b0ad7dcea67f72ba8",
     "third-party/gpui-kit/crates/base/src/text/inline.rs": "415628b573bbb0f39671a9557422d479bd36f8ac234da63f31a5d2ac508e1fa5",
@@ -964,7 +965,201 @@
     "third-party/gpui-kit/crates/base/src/undo_history.rs": "ed5b8f83b9de3d749855d2c9bb06097693c6d9664fb201d7a692801eca4fc1ce",
     "third-party/gpui-kit/crates/base/src/virtual_list.rs": "9a93638f5a2e15f1c4b3c87ca0426cee421341cf7a067da1809e12e67c1c9408",
     "third-party/gpui-kit/crates/base/src/window_border.rs": "fa50193a00edb19c4f8658339a9cb948ca1a32b2747411ae1da5578c16ba5b59",
-    "third-party/gpui-kit/upstream.json": "63520c91d8541e1d629aae42ee3f6486af013d7b71f1ef7c7dce2a49d137c21e",
+    "third-party/gpui-kit/crates/shell/Cargo.toml": "c5b4b9413e7b674e66b499dc4ba78a0358f806656c7cc8447117d015efa46fe5",
+    "third-party/gpui-kit/crates/shell/README.md": "3df51c049ab2ea10799eff9488690dcdcfaa41124c0ef0278a4a3e4566bde838",
+    "third-party/gpui-kit/crates/shell/component-inventory.json": "0e3596c0f5df8bcff88d6031b7f07a94d60e4e28c81db5cc0ff414b60741dc0f",
+    "third-party/gpui-kit/crates/shell/rquickjs-compat/Cargo.toml": "cd6a0e1b53913eb4f2559b79aae3b7877b41e9c559c56a0231501cbd0fe2ad3a",
+    "third-party/gpui-kit/crates/shell/rquickjs-compat/src/lib.rs": "bb742dbe37d7e8494e7279d0f3f3ecd0a7df30ebc66696829aa3eaa507fb9e42",
+    "third-party/gpui-kit/crates/shell/rquickjs-compat/tests/identity.rs": "f78d4fbdc0bd78efb5199c57d6e5f8d76c71bd757e7cd15c2638f2e378de5f65",
+    "third-party/gpui-kit/crates/shell/src/a11y.rs": "49c7a3e82b452846e2b7356b5f0cd9f92d93ea448d5153a3c1608a3a4247c1b2",
+    "third-party/gpui-kit/crates/shell/src/action.rs": "221fd1b69094e5e27f9081c62f54d5a79096d9650f77e2de95bc44ead064cc7c",
+    "third-party/gpui-kit/crates/shell/src/assets.rs": "9a2adb1725b5453bb7e76a3d648bf72dc384e0a6ea2fc1d05f89f7a5a72ac8cc",
+    "third-party/gpui-kit/crates/shell/src/bin/gpui-shell.rs": "780f2a21e91ef67958870327eeba904d11ddee14a170711badc54470b82f900a",
+    "third-party/gpui-kit/crates/shell/src/capability.rs": "93db9f31b18accfe8f665d70f40f0025acc575bcefab5003d45c8359e619bbf7",
+    "third-party/gpui-kit/crates/shell/src/component.rs": "b68f529033bd78e669385c2a8b6e13c2f34657227bed2e61e2d5fabb83f00b58",
+    "third-party/gpui-kit/crates/shell/src/component_registry.rs": "bc48632fea79ca62e2e4bceda40b4caf41082823b9e59303f58ec4950d363933",
+    "third-party/gpui-kit/crates/shell/src/components/mod.rs": "be55c5061c92998b97c38f1e92f42159e8379fb4f1b42f795d99fbd3058eaecd",
+    "third-party/gpui-kit/crates/shell/src/components/shell/basic/dropdown_button.rs": "89cbc1fdf0f0039f9dc208e859a689295070712bc534e5a24b52cfd8f293bea0",
+    "third-party/gpui-kit/crates/shell/src/components/shell/basic/mod.rs": "b3fb0dd8ffde717f42ea2e8f4da2ca42b60849312470316fb9469ff123cadce4",
+    "third-party/gpui-kit/crates/shell/src/components/shell/basic/text.rs": "5ca10f8ba55ebc2e6df1fc58842d29af575727366cfa2108a30d77bd412b8109",
+    "third-party/gpui-kit/crates/shell/src/components/shell/carousel.rs": "134281a37b0f404bfafb7e73bfe6e4a492d93dfd9bfc2ddf43cd6b1b2a39d888",
+    "third-party/gpui-kit/crates/shell/src/components/shell/chart/mod.rs": "a2376fdbe44ed2b1400066ece4f026c2d0fe88aa9efe8dd188d346082caefcaf",
+    "third-party/gpui-kit/crates/shell/src/components/shell/chat.rs": "afa0e535f1dd62000d429858b30bb59bb009945d10324c3fcc1e8524e8325326",
+    "third-party/gpui-kit/crates/shell/src/components/shell/collections/mod.rs": "25646a24666c1fbabb5a6bf01f5afb848760d7706fe42ee565e7a6000ab5cdcf",
+    "third-party/gpui-kit/crates/shell/src/components/shell/collections/tree.rs": "be6c66c27ce1926aa3084fc35d1ea6affaee94c757749cb18b3ada26df85e116",
+    "third-party/gpui-kit/crates/shell/src/components/shell/command/command.rs": "e194c7e13ba682e4fbc336c41facfdb7f42d2b71108102a6c30c0d20bc3dece7",
+    "third-party/gpui-kit/crates/shell/src/components/shell/command/mod.rs": "029fd5bfdef337bbc2d7e7c3ece67453202e3a98003c5331d85c50fc379773db",
+    "third-party/gpui-kit/crates/shell/src/components/shell/command/native_menu.rs": "8644ae02c6f7860f747f56454c97bbc3a44b8cd9267565ff266222b9f4515bfe",
+    "third-party/gpui-kit/crates/shell/src/components/shell/compound/avatar.rs": "8c002976c9fb3ac79a9e7ba29ebbc658195c70c19a981eebf17948dd0473a27f",
+    "third-party/gpui-kit/crates/shell/src/components/shell/compound/collapsible.rs": "2eb8796d01204cd9be078ad8d2b60d3389a64c42a610c8a252edb84482c73597",
+    "third-party/gpui-kit/crates/shell/src/components/shell/compound/common.rs": "02df2dc013fe30286638175857c3e5d6364d078a2f37126bd03957ca2dd42892",
+    "third-party/gpui-kit/crates/shell/src/components/shell/compound/mod.rs": "81404827521ce6e0181584e7f77ee4dbefea8c40f83e46bd5cbb365aa694efb1",
+    "third-party/gpui-kit/crates/shell/src/components/shell/compound/pagination.rs": "100f550662ddc9e7acf3107b6f505a7c97d1d46cfb9b65d4574477281f76eeb6",
+    "third-party/gpui-kit/crates/shell/src/components/shell/compound/progress.rs": "be0d224062f238a660040b86043dafa9fa25c4632e35547b067e5cf1de9126a7",
+    "third-party/gpui-kit/crates/shell/src/components/shell/compound/radio.rs": "132b343e157028062d8927c59127e8ee8e3a59ba62c7664496791a600440b922",
+    "third-party/gpui-kit/crates/shell/src/components/shell/controls/action.rs": "b4c9d19ebd8d5cb77404ecd7a37613d5be37123ab06244bd5a3e6cbe8b398e8d",
+    "third-party/gpui-kit/crates/shell/src/components/shell/controls/display.rs": "ce35a0087e0f7d1aaeb5a41b03815015721e761552108344cd1b182feb2aecc4",
+    "third-party/gpui-kit/crates/shell/src/components/shell/controls/mod.rs": "680cf9d4c9e59aa46376f2f79b6e7459181c44c7e8574c6cffba9c58f4e2d6e6",
+    "third-party/gpui-kit/crates/shell/src/components/shell/controls/support.rs": "09b0f9f6fa38d62c8a341f757e3a31316ee1034b4660250d59d2ad24f81ed966",
+    "third-party/gpui-kit/crates/shell/src/components/shell/controls/text.rs": "cebe376c869b50ea27702b24c5c0af13d96969597668a9963c53ea979ea301de",
+    "third-party/gpui-kit/crates/shell/src/components/shell/data_table/mod.rs": "c4aadfa860f0f9260af41d993cdbb6e7eeeb4aa40649d08a0e298bb988fb0807",
+    "third-party/gpui-kit/crates/shell/src/components/shell/delegate_collections/list.rs": "bbcb384e7e02bf5f23d349799cff53af1ece219f55e556c49f0569b7407a8f6a",
+    "third-party/gpui-kit/crates/shell/src/components/shell/delegate_collections/mod.rs": "d58ca4a0b0dbe390b2baffa9ded10a183973bdd3191fe1ee3126f10e12252084",
+    "third-party/gpui-kit/crates/shell/src/components/shell/delegate_combobox/mod.rs": "393abff32a33087fa11a2feab0ee6f2d1f5d82cdfee2edea0d4d74f341292e75",
+    "third-party/gpui-kit/crates/shell/src/components/shell/delegate_select/mod.rs": "1e708b0e286adc802f12211ed5def1ae3d52a01ee68bb4e40dc5a6697ae3861f",
+    "third-party/gpui-kit/crates/shell/src/components/shell/display/alert.rs": "8895b75236c7cc608c225a3abce2d36a6a43c08072852344d2a19ad0f9f61afd",
+    "third-party/gpui-kit/crates/shell/src/components/shell/display/breadcrumb.rs": "caca7a756bfb60c204059cb0cfda10a32a251c0030b574906878d75871403492",
+    "third-party/gpui-kit/crates/shell/src/components/shell/display/clipboard.rs": "5a586fa7824cbec85a1a8ff5c3510dfcce172a0d5c278751fc098fe371ab43ff",
+    "third-party/gpui-kit/crates/shell/src/components/shell/display/common.rs": "a039eb58c0a3b9920d2ca85902e5ab0ae4a36acdd03f8e78232197f5aff2c895",
+    "third-party/gpui-kit/crates/shell/src/components/shell/display/group_box.rs": "d6fe76af4675cff53220dd6f4b5bf86efa6fbb86fa2da9c9616af567126a9300",
+    "third-party/gpui-kit/crates/shell/src/components/shell/display/mod.rs": "161ddf9007e06046098f845d7f2da7b7749597c58a1cfd3994fd23042f2e9fe4",
+    "third-party/gpui-kit/crates/shell/src/components/shell/display/rating.rs": "40814140bc127ca5646f51fb03b93972c9dfdb9ff9217ba919beaa775f9753c9",
+    "third-party/gpui-kit/crates/shell/src/components/shell/display/status_bar.rs": "7090d67902d2b9db16ffc982c41b11cc9507d5f26d59aabc396e97c1bdc365a1",
+    "third-party/gpui-kit/crates/shell/src/components/shell/display/title_bar.rs": "ab602eb895c1896e8e440c5013400f0ddbf354d38667f46ffc7d6eb0f40e2210",
+    "third-party/gpui-kit/crates/shell/src/components/shell/display/toolbar.rs": "834eab0f7bebfcb9939d164448b9c788a5967df878c948ab51902c3c88212bf3",
+    "third-party/gpui-kit/crates/shell/src/components/shell/empty.rs": "6776ffcae347026a59657fc8775eaa703784451de2c48c587afce143f647806e",
+    "third-party/gpui-kit/crates/shell/src/components/shell/input_group/binding.rs": "0e648b898fda04f7343e528a9a8e976ddb70d691fb7d1b0e1e19dc0bd1596965",
+    "third-party/gpui-kit/crates/shell/src/components/shell/input_group/content_type.rs": "037b3c8569bd2bb9214d2aec0293a1988e621f382dfed127188b66891df85fb5",
+    "third-party/gpui-kit/crates/shell/src/components/shell/input_group/mod.rs": "fd7277f7414ae1895eb90c41be2bcceb5782169c0af3224a7d2f3d677407dd86",
+    "third-party/gpui-kit/crates/shell/src/components/shell/input_tokens.rs": "0f6a09423570e17e923847e88f47434a1fff724025c2017d5a75aa693ecba1b5",
+    "third-party/gpui-kit/crates/shell/src/components/shell/layout/mod.rs": "5899b80b6fd3f44153e58a64f828340b678bdf963b65e94b3a34f5da72392666",
+    "third-party/gpui-kit/crates/shell/src/components/shell/layout/resizable.rs": "07e6f3a16a3644d35d431ea47fb562cb8dc7470ffd73d21cef3239b8a1481cd0",
+    "third-party/gpui-kit/crates/shell/src/components/shell/layout/textarea.rs": "83a12bfc4a4e6a4e44821e7da1b50e9744a48f476a8c8af979d414477e6df467",
+    "third-party/gpui-kit/crates/shell/src/components/shell/lifecycle/menu.rs": "a8a6cfdc701d2c2f5a038285855a0e0a46306a7c45e87198f7e12672b185c360",
+    "third-party/gpui-kit/crates/shell/src/components/shell/lifecycle/mod.rs": "6d1754ec6977ad9f421c9c78afadb17c68a5d399ef0e8ad1b957e509552a3a12",
+    "third-party/gpui-kit/crates/shell/src/components/shell/lifecycle/tooltip.rs": "0812019de4e2161f2ebfb0f94d148905a963fc9f7bad460de08ad82304a6b04a",
+    "third-party/gpui-kit/crates/shell/src/components/shell/media/diff.rs": "c6e1df8dc5afbde5564567a110431e6461724d0dcf1cb80cfc090bc25fa60b86",
+    "third-party/gpui-kit/crates/shell/src/components/shell/media/editor.rs": "4ee6c0552453515c3803ed7211464bff39ff469ab7d68877ac55d66b09681b88",
+    "third-party/gpui-kit/crates/shell/src/components/shell/media/image.rs": "9f526fd9215ad828ca1f307e2b11818d6343e8b1f9147bbb6483ab1147009ffc",
+    "third-party/gpui-kit/crates/shell/src/components/shell/media/mod.rs": "4d94bbd8c51681d6ece85a0245d6d67f5f139548102f6f46a33ae6e1968d84c8",
+    "third-party/gpui-kit/crates/shell/src/components/shell/mod.rs": "be8342aaf3b9f5cebe06f309ecfbd3a250e60df03ba98d9f6f73089b8f4db606",
+    "third-party/gpui-kit/crates/shell/src/components/shell/navigation/icon.rs": "1d627644b7475a2c0adc8bdf0c1c4a88e31b96b9bd5a0f36611172a33fbb422b",
+    "third-party/gpui-kit/crates/shell/src/components/shell/navigation/mod.rs": "4e6cbf3d962d2344d54600440c13695184ee31154323bf751541604cfa66dc9d",
+    "third-party/gpui-kit/crates/shell/src/components/shell/navigation/sidebar.rs": "7c163bcc6adf1aa17185003296758f3f71ab30a6bf8e0e9e2aa791677ec08b8a",
+    "third-party/gpui-kit/crates/shell/src/components/shell/overlays/dropdown_menu.rs": "6348e1a1e4cd1cb32cac52940eddbdc1a91f422d5e467aad650b97d6a919ff7f",
+    "third-party/gpui-kit/crates/shell/src/components/shell/overlays/hover_card.rs": "e092abc0bb99b904dd6d3e3871588e0d1d587083c69731ae06a1ef04db0ae7e7",
+    "third-party/gpui-kit/crates/shell/src/components/shell/overlays/mod.rs": "e817c99bc5bc802712a5aa5959b287d5c57adcda462b5842ef969f002ac86555",
+    "third-party/gpui-kit/crates/shell/src/components/shell/overlays/popover.rs": "8345dd410b27772256d39e70f98d285552a4711d21817e2714ea14545721ff7a",
+    "third-party/gpui-kit/crates/shell/src/components/shell/questionnaire/mod.rs": "f0c07b188175ded1e3bb2a4923449a781b6b0a48770f428e9d08712a586ffab6",
+    "third-party/gpui-kit/crates/shell/src/components/shell/retained_forms/mod.rs": "5f7779ed8f1ba7fc45ff5804bab4c4a198e12e645004c023d4417dc9b8d18c94",
+    "third-party/gpui-kit/crates/shell/src/components/shell/scroll/mod.rs": "583b810340f2d97eea438db2a9fd615448558fb66018d24975959a8a5e82c4d9",
+    "third-party/gpui-kit/crates/shell/src/components/shell/scroll/scroll.rs": "52af54f3b73deb227ab971d19705e7a357b897df51caf8cedf5c81e420cb4ee9",
+    "third-party/gpui-kit/crates/shell/src/components/shell/separator.rs": "937ad1715b241ecc44c8f899799c3f4b9924a9a46ae0d895ed9319c388f0da84",
+    "third-party/gpui-kit/crates/shell/src/components/shell/settings/mod.rs": "06339b0193f8cd95723d14c8b59c27c9d3f3c6e8d7e34e60c1d28ff33d68f1c4",
+    "third-party/gpui-kit/crates/shell/src/components/shell/skeleton.rs": "21f652cc7a5c3c786903e36cf85bf018f5e0cad9b4b3babdc97aa17a5d5cff85",
+    "third-party/gpui-kit/crates/shell/src/components/shell/spinner.rs": "f955f7f09f529ad57fcafbe55f40bc4cfd2df210d34c92e29c9a3e6e180a8010",
+    "third-party/gpui-kit/crates/shell/src/components/shell/structured/common.rs": "d460ea530bf3f8bcd341ae096c5fcf5a105e2e1a357e6e2d4bcfcfd2d902da3d",
+    "third-party/gpui-kit/crates/shell/src/components/shell/structured/description_list.rs": "4977c8e148965177da47e1bd2787d43dfdc5242c48b6257fe9d6c39c0f3bffc8",
+    "third-party/gpui-kit/crates/shell/src/components/shell/structured/form.rs": "537dcac42945bfe49ec0255c84126594c00344b0c0f1b9f6c548c033d966f43b",
+    "third-party/gpui-kit/crates/shell/src/components/shell/structured/mod.rs": "c8da006fc7400121d51802a650011405d8f94667e0f3463217d7354c4f9b5bc4",
+    "third-party/gpui-kit/crates/shell/src/components/shell/structured/table.rs": "c985d06b646441345bc155277684463d75be7346bd3fc32c2d4bc2d72f272a17",
+    "third-party/gpui-kit/crates/shell/src/components/shell/support.rs": "3ef639bcb8587e3f19663552c08db89f09789e094e3a9a84e0c789968295661a",
+    "third-party/gpui-kit/crates/shell/src/components/shell/typed_child.rs": "4284bb1be58cfc2dc5c3b1bd39672f8b3fa7784a603ad7c4b89cbdc9e7a9a01d",
+    "third-party/gpui-kit/crates/shell/src/components/shell/typed_compound/mod.rs": "c2027159972a3f575cefd5fbf3140cc9ba94fbe80fa00fe7378091a0b1d3a69f",
+    "third-party/gpui-kit/crates/shell/src/components/shell/window_effects/mod.rs": "fbf6a4ed214c5b6e4445c8920a244a04cee264944d54fe111892f18ad2cd66ce",
+    "third-party/gpui-kit/crates/shell/src/default-tokens.json": "0968d8329bc4498c28cd9c1e022ae161caebeedc0343c74b0a59cd30b7c94308",
+    "third-party/gpui-kit/crates/shell/src/dependencies.rs": "d64c218bd9fe31111b140e6f41b1c6ba7268f9e1d05bde9e5f036e4c3fa396bb",
+    "third-party/gpui-kit/crates/shell/src/dock.rs": "feb63823d83357a3e969c3bf06cd9afb7e2e6071b0ee17acbf1108e0b9305aeb",
+    "third-party/gpui-kit/crates/shell/src/engine/mod.rs": "89e4ce95713b4a2824dd834eb2eb94bac22cd50ae89c58469885a024b2b25bc0",
+    "third-party/gpui-kit/crates/shell/src/engine/quickjs/dock_api.rs": "aeecd707b956ec05455aa4a19f85ab6ac4fd859abf116b33fde4b99a6968ae3c",
+    "third-party/gpui-kit/crates/shell/src/engine/quickjs/entity_api.rs": "83df83a43a118676beeb5e73535d68ac13dfefad21f694a481f3aad3aebccac8",
+    "third-party/gpui-kit/crates/shell/src/engine/quickjs/host.rs": "d85adceef8c4a65174d90ce52ca3ebc3bf69ea227388a4f33b239d06a78522f2",
+    "third-party/gpui-kit/crates/shell/src/engine/quickjs/host_modules.rs": "8a5d139baffb97518a463ca8695ceb4b607716b5033598597fe4e5e811ce83ec",
+    "third-party/gpui-kit/crates/shell/src/engine/quickjs/mod.rs": "be47d96b6598ce260e0b1fc338287b4355f4b498d792fe21f01e5200871a7d57",
+    "third-party/gpui-kit/crates/shell/src/engine/quickjs/overlay.rs": "b2ed2a3bf5fd3b0cceb59b47acc10ed8c01ee4150979b7f0ab3a372fb9aa2d09",
+    "third-party/gpui-kit/crates/shell/src/engine/quickjs/sandbox.rs": "0f2356d5038f6c9b867aa4477d60a1ef839bc750b621daebf3b57b13266826c1",
+    "third-party/gpui-kit/crates/shell/src/engine/quickjs/scheduler.rs": "f6d4802ccf8a495cc96c51319d1464cdc397bbdb073a75379126c3ed60a7b973",
+    "third-party/gpui-kit/crates/shell/src/engine/quickjs/standard/connect.rs": "a1706d46dcc1b2cab62b4d65b409193e625013c9d50aaae07f496dfd4dd73ece",
+    "third-party/gpui-kit/crates/shell/src/engine/quickjs/standard/console.rs": "108085f14389e704b2d5a3b37b7139f103e9b7d5933bc8f810dad9607bc9f4b3",
+    "third-party/gpui-kit/crates/shell/src/engine/quickjs/standard/fetch.rs": "93d33dd9e5fcfd24cd373c8ec588ee3f00971a1eddd966b2435743a0d395f31f",
+    "third-party/gpui-kit/crates/shell/src/engine/quickjs/standard/fs.rs": "4ac0ca532f558788bfc7e4558223ca6476e65382b59026b10d02303c41d45ee9",
+    "third-party/gpui-kit/crates/shell/src/engine/quickjs/standard/mod.rs": "f406c16294c5ee30cac9d9c6b7d30efcfde57a2f42dcec900b221dc0a376343e",
+    "third-party/gpui-kit/crates/shell/src/engine/quickjs/standard/net.rs": "dc73c8ad5fda62d11d2c86768cdf5c235fbb62a64c8bc0ca379fdd29cbdd0177",
+    "third-party/gpui-kit/crates/shell/src/engine/quickjs/standard/os.rs": "bf698d43b79b5260ec5228677bf6c41537cdfa342193613f6579c911cc9361e9",
+    "third-party/gpui-kit/crates/shell/src/engine/quickjs/standard/process.rs": "e27b38b9713c6402ccacb511db8a8d4d410907d92ef1ab3bb5ba9a6229d489f2",
+    "third-party/gpui-kit/crates/shell/src/engine/quickjs/standard/websocket.rs": "49f870b9265459237c30b6020eecb08009ed94b6aa245785b2cbba7d42531694",
+    "third-party/gpui-kit/crates/shell/src/engine/quickjs/template.rs": "88ecf9a07fc630a053f7e250fb0ccd7aacba7edd2569c02ec42a406d6a192441",
+    "third-party/gpui-kit/crates/shell/src/engine/quickjs/theme_api.rs": "28411db96a117e285cc1d905c6f62656f44a0b635adfc29c02f9367d8897f14a",
+    "third-party/gpui-kit/crates/shell/src/engine/quickjs/window_api.rs": "efeee042703a97ac8fa2a5e4ba1f31c4b6e001b8be489f86a365a6a4c58b434c",
+    "third-party/gpui-kit/crates/shell/src/entities.rs": "1c9689bc012a183dad715112b99c6805f4ab31fcdb26c297b1ed84fd0a0a4bef",
+    "third-party/gpui-kit/crates/shell/src/error.rs": "39474b8d408632c2ee8950ec95a37fb453614116eadc39357ce7eeef544ad3a0",
+    "third-party/gpui-kit/crates/shell/src/host.rs": "ee323667c12d20963410859e03e82884970143001d83be1099fb14bf9b22f4f4",
+    "third-party/gpui-kit/crates/shell/src/host/agents.md": "d426f8cf92a89e9c23cde9377537b507b1908dc9535f7767db20b26547f8d8c4",
+    "third-party/gpui-kit/crates/shell/src/host/agents.rs": "c38d6f0b65647860588d364a34f021dc0fb58e614de0eca6e38c9a40d63301e1",
+    "third-party/gpui-kit/crates/shell/src/host/bundle.rs": "17a0e2203f24fe0ff3bf023f39d37e1bc626de38865c973c82dbc233eb771751",
+    "third-party/gpui-kit/crates/shell/src/host/bundle/linux-install.sh": "596fd248fc5a840437b889ee1675d061a699a79235079467ae4c4a800fcffd18",
+    "third-party/gpui-kit/crates/shell/src/host/bundle/linux-launcher.sh": "efe6df9255465be99c6c50b76c3fce84d9d967a4d070498e38990dbfff289667",
+    "third-party/gpui-kit/crates/shell/src/host/bundle/macos-launcher.sh": "987be9822a100d0b012dca2be47590cfb39c39d530244dcd185177ccc570bd3a",
+    "third-party/gpui-kit/crates/shell/src/host/bundle/runtime.sh": "33ace3cd775cf09d6c1beaeffc907bef86ea0f7fc3ddc6f64572d7f8dee6665a",
+    "third-party/gpui-kit/crates/shell/src/host/installed.rs": "4b77ae17721191eab0044881bbcf98968b206525c9f5564866dd2ecb4a2d57c0",
+    "third-party/gpui-kit/crates/shell/src/host/scaffold.rs": "36f502b980fefd4809447fd78c191749b8c43795005dc0ce50b0f65f737b885e",
+    "third-party/gpui-kit/crates/shell/src/host_modules.rs": "ef28eb06ec8722f89556424a9b5de7fbd2e773a85d403f1b7a51d8b6bbf09b9c",
+    "third-party/gpui-kit/crates/shell/src/input_tokens.rs": "e1b6634b1456a461ab1c242c74427270895770fc76097c9cf3d9dd53928025ab",
+    "third-party/gpui-kit/crates/shell/src/lib.rs": "9b327fe3da2f44bc0091e103c2bc0e975eaa4722cf5dc724e82470998057d30b",
+    "third-party/gpui-kit/crates/shell/src/materialize.rs": "2638b8755a48b10f923f60114bac557df22caa94d4f092f4b781f9a9ffbd18ec",
+    "third-party/gpui-kit/crates/shell/src/materialize/components.rs": "b957e35b5ccc1e764fbb3e8ee7e240705940e5c5847febbf5f0865ca8a25200b",
+    "third-party/gpui-kit/crates/shell/src/materialize/components/accordion.rs": "36263db263d1619c255f86efc21db829c410a2de2bcc463e7c1edc3845757e44",
+    "third-party/gpui-kit/crates/shell/src/materialize/components/avatar.rs": "556a48dfea94c1e111f054b39b8676cde709d1332e4a876714d0da114d70e1f2",
+    "third-party/gpui-kit/crates/shell/src/materialize/components/collapsible.rs": "9b32dadb5778f700dd3e6ef939b1c1fefa215c6c0f20c1f39b42aa5f2eb1f845",
+    "third-party/gpui-kit/crates/shell/src/materialize/components/dock.rs": "4c861739f09e0b82e56f77e1ef4b9a3345aa761e50a859f02125e57cb892ac54",
+    "third-party/gpui-kit/crates/shell/src/materialize/components/fps.rs": "31ea62c5ec56d82f569816fcfa53e527aa89a843af0d20cb325a46b9165f9a48",
+    "third-party/gpui-kit/crates/shell/src/materialize/components/group.rs": "998120af2816a4a91db6af53225f0463d9a039d6c976c0290834d1f7ce4948f3",
+    "third-party/gpui-kit/crates/shell/src/materialize/components/list.rs": "7261312d4b2d052d38b997874179c19cff39b3d7771c330708a7d536147754a4",
+    "third-party/gpui-kit/crates/shell/src/materialize/components/number_input.rs": "6aa29f1b674e5b3e73036bfd66a899ad3e3cc23378d23737cfc0e35ab7622426",
+    "third-party/gpui-kit/crates/shell/src/materialize/components/otp_input.rs": "dc712b9edd35c6cdbd439346ac6c0a9dd5e0c3ae62f17bb468656f278a1fadb9",
+    "third-party/gpui-kit/crates/shell/src/materialize/components/pagination.rs": "8a0505628f482c26a8b58ef70ca63e7be10a868ab099b689d56001d294bd4798",
+    "third-party/gpui-kit/crates/shell/src/materialize/components/popover.rs": "8c4156eb13050346d3488beed60e3ad1108a270da36dad20823ffed447547114",
+    "third-party/gpui-kit/crates/shell/src/materialize/components/progress.rs": "0e20bb671feb555b37555ba5bf4762216fea0930b14b0f1b935afb0ae3535bd7",
+    "third-party/gpui-kit/crates/shell/src/materialize/components/radio.rs": "b84b81a88180f000f6990a8bff36549b51bf9b3d46ae9487996865deda39dee0",
+    "third-party/gpui-kit/crates/shell/src/materialize/components/resizable.rs": "9fe766af0197b6f7fa8b5f593ae7faa20bf6482fdc3d851e6de606ec8815d197",
+    "third-party/gpui-kit/crates/shell/src/materialize/components/scrollbar.rs": "58c8ff524fb22a95295332b6daf1484ca3a2abae260b68e7b206257da485ffd9",
+    "third-party/gpui-kit/crates/shell/src/materialize/components/select.rs": "1a988e71f6e1a535d778f43a2c2c8f071c13b6587226fd73a76c6c5cb8f8b57b",
+    "third-party/gpui-kit/crates/shell/src/materialize/components/slider.rs": "3d4b32d12ae41760bca56e38fc75d702de16feb8b81e97bad99650710116dcde",
+    "third-party/gpui-kit/crates/shell/src/materialize/components/spin.rs": "d0faa16459279da3ecc2b77425f5323df733c4193f76b92a707a0bcd03dc2a74",
+    "third-party/gpui-kit/crates/shell/src/materialize/components/table.rs": "d1c4740e1f8924ec53a971470948553435c255b8efd741ac152b8498e709c7c3",
+    "third-party/gpui-kit/crates/shell/src/materialize/components/tabs.rs": "5a62a3caa1990447ea68c1377108ced9d496055f65d637eac319786bea7cb05e",
+    "third-party/gpui-kit/crates/shell/src/materialize/components/textarea.rs": "fe25ff7123be9e30b2531b85271810b80f892f081f6866530cc36bef6f538ae4",
+    "third-party/gpui-kit/crates/shell/src/materialize/components/toggle.rs": "68c794784ab442467bf7070dd0d2c44a33b5292fdd1c4984223629c69544a539",
+    "third-party/gpui-kit/crates/shell/src/materialize/components/tooltip.rs": "3b682b6edddf8a7e0e83afc7588a458cee637cc504431b2ee7d6a13030d914bf",
+    "third-party/gpui-kit/crates/shell/src/materialize/components/virtual_list.rs": "ade74b879774f22eaa361fee0d753416fc4bcd7ee45c733be47597800dbb120f",
+    "third-party/gpui-kit/crates/shell/src/materialize/text_view.rs": "8f548d46ee78318b650dafa6829c9db4038b535752ddf274ede6837b618e82e5",
+    "third-party/gpui-kit/crates/shell/src/materialize/text_view/style.rs": "cda550412397bc9a62e807dd77d70beabcdd1ec1d6a32faa3ec42944e1e36156",
+    "third-party/gpui-kit/crates/shell/src/materialize/text_view/tests.rs": "03d3293e1ef52ebb4eac5de1f3894f40f17d0cc3736efa15c359e86d55655a0a",
+    "third-party/gpui-kit/crates/shell/src/metrics.rs": "058b00d9903d6983825552c55ce23a3e9cbde5767023564d4e4395a23ad97273",
+    "third-party/gpui-kit/crates/shell/src/path.rs": "9defe2a871fa085b755fd15cebc9467c37a3787f27a2e7d1f9cf68a830d9e089",
+    "third-party/gpui-kit/crates/shell/src/plugin.rs": "f36c3bba39c68d7af882449e1a8a521a38fea386e7cd7a81e025fa341bdbee17",
+    "third-party/gpui-kit/crates/shell/src/policy.rs": "3009c6412800f4b507e208145dba49570144a46c31695a64b5d95755490414b4",
+    "third-party/gpui-kit/crates/shell/src/process.rs": "9ef7fc0b42d1b4cc7e1a55e45bc59a1ea94c6832870294eabe7493bbe826b718",
+    "third-party/gpui-kit/crates/shell/src/root.rs": "271bacefb58be70d6cf2565b58cf540cf159c2a023473dd3fe7d9fd3afb1d4f5",
+    "third-party/gpui-kit/crates/shell/src/runtime.rs": "c0abac2450b2973c22a21da34a38daeb2b2eaeb0dc16715aaea073d6f18a43e2",
+    "third-party/gpui-kit/crates/shell/src/scope.rs": "412e074e3253eca9a394f6347242bff5b61bf9126f86dc01321cd35ecd75df1c",
+    "third-party/gpui-kit/crates/shell/src/script_callback.rs": "a47805ff8c935958f740ea9c264eab66a64b7a37a21a6ff40c031e1fd31d4c49",
+    "third-party/gpui-kit/crates/shell/src/scroll.rs": "860272f6c0f85ad02bac9fa1edff104ae5fb5b214862512c461ef11de416403d",
+    "third-party/gpui-kit/crates/shell/src/snapshot.rs": "b714170f2e128b86d365e3681a2e1bb22667c8a06da949cdfeb49acd4e80fd41",
+    "third-party/gpui-kit/crates/shell/src/spec.rs": "98bdc5a5ab81114e34f989f8ed943bd1c4b03301c57e52c2b5d989e047e8d020",
+    "third-party/gpui-kit/crates/shell/src/storage.rs": "1d03aa5767e27b62a88741ab644e35243f07206699f5a448b050d371e9e88a83",
+    "third-party/gpui-kit/crates/shell/src/style.rs": "ece8ba3e9eeaeb0e28c5424afc44140b0cfd2f00dad892e7419bfe34871c12de",
+    "third-party/gpui-kit/crates/shell/src/tests/benchmark.rs": "43efcf67d1415ef499daa1d5d1bdf64c74a9b5a49878359ae55a598fdf1972c6",
+    "third-party/gpui-kit/crates/shell/src/tests/dock.rs": "0e2b8ae7d006499187e46f45156810695780d3f6b41ea5f56ae6a25dfbe7ee35",
+    "third-party/gpui-kit/crates/shell/src/tests/fs.rs": "2394f1163bcf38af80f2f5483ba164cb643ad6e9d44d9534654a52a2ce69fe24",
+    "third-party/gpui-kit/crates/shell/src/tests/host_api.rs": "d8ee38eebef9e280251f31099f85abbfd67edf89699f208b2a59c123b2e61099",
+    "third-party/gpui-kit/crates/shell/src/tests/http_request.rs": "da609339b11c6a99debea0e366b4e4ee72c95dc66517850b0149c0758b8c2ec6",
+    "third-party/gpui-kit/crates/shell/src/tests/mod.rs": "575279cdfe4c45162fd7088514e074936d9df6f1a631a4dc1cfbf26dc439dda9",
+    "third-party/gpui-kit/crates/shell/src/tests/network.rs": "87b4e3f709a56788bcbdc7b9e720fe7f29cace38903336a16b8b5339088c77d1",
+    "third-party/gpui-kit/crates/shell/src/tests/process.rs": "f2c2fb6a202ac76b93514e8f13ad88e655254d556949e604c055cb5578edbdc4",
+    "third-party/gpui-kit/crates/shell/src/tests/render.rs": "54934f1d3957e8380d4520b3789a7e0dc3c10fec61b60d08cb616f0d0a7e609c",
+    "third-party/gpui-kit/crates/shell/src/tests/snapshot.rs": "16b8b5a6a7337ae303addc999eb12e9f38e30b028c7d44560e78480f23025865",
+    "third-party/gpui-kit/crates/shell/src/tests/spin.rs": "18dda2798a669d077281378e2f0f19af2a1a2418e6e718486932725a2f928cd3",
+    "third-party/gpui-kit/crates/shell/src/tests/standard_runtime.rs": "06fb0d1921924f4b4f9e966476b9a15015eec51f1fb982cbbdad14aec76b485d",
+    "third-party/gpui-kit/crates/shell/src/tests/structure.rs": "3c7d68eedf2f67d3914614a0c140a0cadf0fb180194b4c704c09e724c25d8c47",
+    "third-party/gpui-kit/crates/shell/src/tests/template.rs": "45ce118ba7665228bd29cf367527489ce59f1656578862cbc5ff2e93e24c70bc",
+    "third-party/gpui-kit/crates/shell/src/tests/text_style.rs": "b02cb951fa2884bc05aadff8496d69c0fb974b44460fb7e64f92097df0c14f45",
+    "third-party/gpui-kit/crates/shell/src/theme_tokens.rs": "c7397ebdf686950f569030ae08195d558cc6c6d833d6b47784a5bfff7bda1825",
+    "third-party/gpui-kit/crates/shell/src/typings.rs": "7face9fbcf9668e09c53a6066ab28482cd1a4e29e058c3fd66f6da11be5c8892",
+    "third-party/gpui-kit/crates/shell/src/value.rs": "263d0c298f005891de2e8a50945c852b020c6c9a6af0c2b1dafc35760a47c215",
+    "third-party/gpui-kit/crates/shell/src/view.rs": "a9165bad6b34363f73874e4771531225769414ff041ae3a38552868f91751b47",
+    "third-party/gpui-kit/crates/shell/src/watch.rs": "fcd626acfe003cf6c3d9b236d81fb503d43912813d1f1650825dfd404a34f521",
+    "third-party/gpui-kit/upstream.json": "5c953a6c6a1eb34a1f0068dfdf26b0ac82e82e2e842642f5942a0970c6051826",
     "third-party/image-webp/Cargo.toml": "c05cc68c768c57e79c581b2ad6c112759556df9689b83313e35d5943f2951262",
     "third-party/image-webp/LICENSE-APACHE": "0d542e0c8804e39aa7f37eb00da5a762149dc682d7829451287e11b938e94594",
     "third-party/image-webp/LICENSE-MIT": "c77a4cf9da729987d0fe7ccd811e3bd27393914ddf3d23467c18cc22954513b3",
@@ -1043,5 +1238,5 @@
     "third-party/sqlx-sqlite/upstream.json": "0d9b56e81ace351d771e27f0e247e346fa6605f5e160176ea531d9f7720a10db",
     "tools/schema/Cargo.toml": "28f0d16abedac58f4e3e899081ae8652633fc3619474418ec954d214672abbe8"
   },
-  "sha256": "1f2efbfff118fe0ce56da87f8926c50184beda933ce886524c944f1b5b9d5c45"
+  "sha256": "5aff36a5d38ea4d5ad8e5de0c65d8794a379ce8948eda98fa46488ba9e2b4bb0"
 }
diff --git a/products/maka/desktop-shell/Cargo.toml b/products/maka/desktop-shell/Cargo.toml
new file mode 100644
index 0000000000..ef5756f23e
--- /dev/null
+++ b/products/maka/desktop-shell/Cargo.toml
@@ -0,0 +1,36 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+[package]
+name = "maka-desktop-shell"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+publish.workspace = true
+
+[dependencies]
+gpui-shell.workspace = true
+hex.workspace = true
+maka-capabilities.workspace = true
+maka-host-runtime.workspace = true
+maka-plugin-core.workspace = true
+maka-ui-runtime.workspace = true
+serde_json.workspace = true
+sha2.workspace = true
+tokio.workspace = true
+tokio-util.workspace = true
+uuid.workspace = true
diff --git a/products/maka/desktop-shell/app/.gitignore b/products/maka/desktop-shell/app/.gitignore
new file mode 100644
index 0000000000..8d6f4aee59
--- /dev/null
+++ b/products/maka/desktop-shell/app/.gitignore
@@ -0,0 +1,2 @@
+gpui-kit.d.ts
+dev.js
diff --git a/products/maka/desktop-shell/app/archived.js b/products/maka/desktop-shell/app/archived.js
new file mode 100644
index 0000000000..3a2ea61d36
--- /dev/null
+++ b/products/maka/desktop-shell/app/archived.js
@@ -0,0 +1,275 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Settings › Archived: the tasks the sidebar's menu archived, which the
+// catalog the sidebar watches (filter 'all') no longer lists. `agent.list`
+// with filter 'archived' reads them, and `agent.changeSession` restores one,
+// which the sidebar then lists again by itself, or removes it. Removing
+// deletes the conversation only; the files in its workspace are kept. The
+// native client keeps them on a settings page as well
+// (maka-gpui crates/settings/src/archived_page.rs).
+
+import { div } from 'gpui-kit';
+import { v_flex, h_flex } from 'gpui-base';
+import { query, log } from './host.js';
+import { changeSession, changeFailure } from './sessions.js';
+import { group, settingRow, button } from './settings.js';
+import { palette, typeset, text, controlButton, compactAge } from './theme.js';
+import { t } from './locale.js';
+
+/** Pages read after the first; the Host pages at most 32 sessions in id order. */
+const MAX_PAGES = 32;
+
+export class Archived {
+  /**
+   * @param {{ changed: () => void }} hooks
+   */
+  constructor(hooks) {
+    this.hooks = hooks;
+    /** The archived sessions as last read, newest first, or null before the first read. */
+    this.sessions = null;
+    this.loading = false;
+    /** Counts the reads; an answer for an earlier one is dropped. */
+    this.reads = 0;
+    /** Why the last read failed, in words, until one succeeds. */
+    this.loadError = null;
+    /** The id of the session being restored or deleted, or null; one change at a time. */
+    this.busy = null;
+    /** Why the last restore or delete did not go through, in words, or null. */
+    this.error = null;
+    /** The id of the session whose deletion waits for a confirmation, or null. */
+    this.removing = null;
+  }
+
+  /** Reads every archived session, newest first. */
+  async load() {
+    const read = ++this.reads;
+    this.loading = true;
+    this.loadError = null;
+    this.hooks.changed();
+    try {
+      const sessions = await readArchived();
+      if (read !== this.reads) return;
+      this.sessions = sessions;
+      log(`archived: ${sessions.length} read`);
+    } catch (error) {
+      if (read !== this.reads) return;
+      log(`archived: reading failed: ${error.message}`);
+      this.loadError = t('archivedReadFailed', { value: error.message });
+    } finally {
+      if (read === this.reads) {
+        this.loading = false;
+        this.hooks.changed();
+      }
+    }
+  }
+
+  /** Leaving the page drops a question and the last failure. */
+  close() {
+    this.removing = null;
+    this.error = null;
+  }
+
+  restore(session) {
+    return this.change(session, 'restore', (value) => t('restoreFailed', { value }));
+  }
+
+  /** Asks before deleting, in the session's row. */
+  askRemove(session) {
+    if (this.busy !== null) return;
+    this.removing = session.id;
+    this.error = null;
+    this.hooks.changed();
+  }
+
+  cancelRemove() {
+    this.removing = null;
+    this.hooks.changed();
+  }
+
+  remove(session) {
+    this.removing = null;
+    return this.change(session, 'remove', (value) => t('deleteFailed', { value }));
+  }
+
+  /**
+   * Restores or removes the session, then reads the list again, whether it
+   * went through or not. A failure is said in the page's status line, in the
+   * words the sidebar's Archive uses.
+   * @param {(value: string) => string} failed
+   */
+  async change(session, action, failed) {
+    if (this.busy !== null) return;
+    this.busy = session.id;
+    this.error = null;
+    this.hooks.changed();
+    try {
+      const result = await changeSession(session, action);
+      log(`archived: ${action} ${session.id}: ${result.kind}`);
+    } catch (error) {
+      log(`archived: ${action} ${session.id} failed: ${error.message}`);
+      this.error = changeFailure(error, failed);
+    } finally {
+      this.busy = null;
+      this.hooks.changed();
+    }
+    await this.load();
+  }
+}
+
+/** Reads made after a later page failed, as the catalog changed meanwhile. */
+const MAX_READS = 3;
+
+/**
+ * Every page of `agent.list` with filter 'archived', at the first page's
+ * revision, newest first. A later page fails once the catalog has changed
+ * since the first (any session's change moves the revision), and the read
+ * then starts over.
+ */
+async function readArchived() {
+  for (let read = 1; ; read += 1) {
+    const first = await query('agent', 'list', { filter: 'archived', position: { kind: 'start' } });
+    const sessions = [...first.sessions];
+    let after = first.after;
+    try {
+      for (let count = 0; after !== null && count < MAX_PAGES; count += 1) {
+        const page = await query('agent', 'list', {
+          filter: 'archived',
+          position: { kind: 'after', revision: first.revision, cursor: after },
+        });
+        sessions.push(...page.sessions);
+        after = page.after;
+      }
+    } catch (error) {
+      if (read === MAX_READS) throw error;
+      log(`archived: later pages: ${error.message}; reading again`);
+      continue;
+    }
+    return sessions.sort((a, b) => b.updatedAt - a.updatedAt);
+  }
+}
+
+/**
+ * The line under the page's description: why reading failed, with Retry, or
+ * why the last restore or delete failed. Null when there is nothing to say.
+ * @param {Archived} archived
+ */
+export function archivedStatus(archived, cx) {
+  const m = palette(cx);
+  if (archived.loadError !== null) {
+    return h_flex()
+      .gap(8)
+      .items_center()
+      .child(text(archived.loadError, 'small', m.destructive).flex_1().min_w_0())
+      .child(
+        button('archived-retry', t('retry'), false, archived.loading, cx)
+          .h(28)
+          .on_click(() => archived.load()),
+      );
+  }
+  if (archived.error !== null) return text(archived.error, 'small', m.destructive);
+  return null;
+}
+
+/**
+ * The archived tasks, one row each, or a line saying there are none.
+ * @param {Archived} archived
+ */
+export function archivedSection(archived, cx) {
+  const m = palette(cx);
+  if (archived.sessions === null) return div();
+  const rows = archived.sessions.map((session) => archivedRow(archived, session, cx));
+  if (rows.length === 0) rows.push(text(t('noArchivedTasks'), 'body', m.inkMuted).py(8));
+  return group(null, null, null, rows, cx);
+}
+
+/**
+ * One archived task: its title; the folder it works in and when it was
+ * archived, as the sidebar writes ages; Restore and Delete…. Delete asks in
+ * the row first, with what deleting keeps.
+ */
+function archivedRow(archived, session, cx) {
+  const m = palette(cx);
+  const busy = archived.busy !== null;
+  const confirming = archived.removing === session.id;
+  const path = session.workspace?.path ?? '';
+  const folder = path.split('/').filter(Boolean).pop() ?? t('noFolder');
+  return v_flex()
+    .w_full()
+    .child(
+      settingRow(
+        session.title,
+        `${folder} · ${compactAge(session.updatedAt)}`,
+        h_flex()
+          .gap(8)
+          .child(
+            button(`archived-restore-${session.id}`, t('restoreTask'), false, busy, cx)
+              .h(28)
+              .on_click(() => archived.restore(session)),
+          )
+          .child(
+            controlButton(
+              `archived-delete-${session.id}`,
+              t('deleteTask'),
+              'outline',
+              busy || confirming,
+              cx,
+            )
+              .h(28)
+              .text_color(m.destructive)
+              .on_click(() => archived.askRemove(session)),
+          ),
+        cx,
+      ),
+    )
+    .when(confirming, (el) =>
+      el.child(
+        h_flex()
+          .w_full()
+          .pb(10)
+          .gap(12)
+          .items_center()
+          .child(
+            v_flex()
+              .flex_1()
+              .min_w_0()
+              .child(typeset(div(), 'label').text_color(m.ink).child(t('deleteTaskQuestion')))
+              .child(text(t('deleteTaskHelp'), 'small', m.inkMuted)),
+          )
+          .child(
+            controlButton(
+              `archived-delete-confirm-${session.id}`,
+              t('deleteAction'),
+              'primary',
+              busy,
+              cx,
+            )
+              .h(28)
+              .bg(m.destructive)
+              .when(!busy, (el) => el.hover((style) => style.bg(m.destructive).opacity(0.84)))
+              .on_click(() => archived.remove(session)),
+          )
+          .child(
+            button(`archived-delete-cancel-${session.id}`, t('cancel'), false, busy, cx)
+              .h(28)
+              .on_click(() => archived.cancelRemove()),
+          ),
+      ),
+    );
+}
diff --git a/products/maka/desktop-shell/app/composer.js b/products/maka/desktop-shell/app/composer.js
new file mode 100644
index 0000000000..0b24275be1
--- /dev/null
+++ b/products/maka/desktop-shell/app/composer.js
@@ -0,0 +1,796 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// The composer: a multi-line draft in a rounded dock, the files attached to
+// it, the model menu, the folder a new task starts in, and Send or Stop.
+
+import { div } from 'gpui-kit';
+import { v_flex, h_flex, Button, Textarea, TextareaState, Popover } from 'gpui-base';
+import { chooseFiles, release } from 'maka-files';
+import { query, operation, log, readModels } from './host.js';
+import {
+  palette,
+  typeset,
+  text,
+  icon,
+  ghostButton,
+  iconButton,
+  fileChip,
+  menuSurface,
+  menuRow,
+  menuLabel,
+  menuSeparator,
+  controlButton,
+} from './theme.js';
+import { t, onLanguageChange } from './locale.js';
+
+/** The most files one message carries (`agent.send` `attachments`). */
+const MAX_ATTACHMENTS = 8;
+
+/** Why the Host refused an upload (`AttachmentRejection`), in words. */
+const REJECTIONS = {
+  invalid_file: 'attachmentInvalidFile',
+  closed: 'attachmentClosed',
+  archived: 'attachmentArchived',
+};
+
+export class Composer {
+  /**
+   * Creates retained input state, so it belongs in the view's `init`.
+   * @param {{ changed: () => void, report: (message: string) => void }} hooks
+   * @param {() => import("./transcript.js").Transcript | null} target the session Enter sends to
+   * @param {() => Promise<import("./transcript.js").Transcript | null>} start starts a session
+   *   when none is selected, and resolves with it
+   */
+  constructor(hooks, target, start) {
+    this.hooks = hooks;
+    this.start = start;
+    this.draft = TextareaState.new({ placeholder: t('composerPlaceholder'), rows: 1 });
+    this.draft.set_auto_grow(1, 8);
+    // The placeholder follows a change of language; the draft lives as long as the app.
+    onLanguageChange(() => this.draft.set_placeholder(t('composerPlaceholder')));
+    // Send turns on and off with the draft; redraw only when that flips.
+    this.empty = true;
+    this.draft.on('change', () => {
+      const empty = this.draft.value().trim() === '';
+      if (empty !== this.empty) {
+        this.empty = empty;
+        this.hooks.changed();
+      }
+    });
+    // Enter sends without inserting a newline; Shift+Enter inserts one and
+    // still reports `submit`, with `shift` set.
+    this.draft.set_submit_on_enter(true);
+    this.draft.on('submit', (event) => {
+      if (!event.shift) this.send(target());
+    });
+    /** The default model's choice, or the first offered one, for sessions without their own. */
+    this.model = null;
+    /** That model's name, or null when there is none (the footer then says so). */
+    this.modelLabel = null;
+    /** Whether the model list has been read once, so "no model" is known rather than pending. */
+    this.loaded = false;
+    /** The first page of `settings.models`, used to name a session's own model. */
+    this.choices = [];
+    this.sending = false;
+    this.stopping = false;
+    /** Whether the model menu is open. */
+    this.menuOpen = false;
+    /** sessionId (or '' for a new task) -> the model chosen in the menu, which the next send uses. */
+    this.chosen = new Map();
+    /** Whether the permission menu is open. */
+    this.accessOpen = false;
+    /** The permission level picked for a new task, or null for the Host's default. */
+    this.newAccess = null;
+    /** Whether a permission change is under way. */
+    this.changingAccess = false;
+    /**
+     * sessionId (or '' for a new task) -> the files attached to that draft,
+     * in order: `{ handle, name, bytes, digest, id, state, error }`. `handle`
+     * is `maka-files`' registration, `id` the Host's once the file is uploaded
+     * to that session (null before), and `state` 'ready', 'uploading' or
+     * 'failed', with `error` saying why.
+     */
+    this.attached = new Map();
+    /** Whether the open panel for attachments is up. */
+    this.choosing = false;
+  }
+
+  /** The files attached to the draft for `transcript`, or for a new task. */
+  attachments(transcript) {
+    return this.attached.get(transcript?.id ?? '') ?? [];
+  }
+
+  /** Whether files can be attached to the draft for `transcript` now. */
+  canAttach(transcript) {
+    return (
+      !this.sending &&
+      !this.choosing &&
+      transcript?.backend?.kind !== 'executor' &&
+      this.attachments(transcript).length < MAX_ATTACHMENTS
+    );
+  }
+
+  /** Shows the open panel and attaches the files chosen to the draft for `transcript`. */
+  async chooseAttachments(transcript) {
+    if (!this.canAttach(transcript)) return;
+    this.choosing = true;
+    this.hooks.changed();
+    try {
+      this.attach(transcript, await chooseFiles());
+    } catch (error) {
+      this.hooks.report(t('attachFailed', { value: error.message }));
+    } finally {
+      this.choosing = false;
+      this.hooks.changed();
+    }
+  }
+
+  /**
+   * Adds files `maka-files` registered to the draft for `transcript`, up to
+   * MAX_ATTACHMENTS in all; those past the limit are released. What was not
+   * attached is reported: files too large, unreadable, or past the limit.
+   */
+  attach(transcript, files) {
+    const attached = [...this.attachments(transcript)];
+    const problems = [];
+    const over = [];
+    for (const file of files) {
+      if (file.tooLarge) {
+        problems.push(t('attachmentTooLarge', { name: file.name }));
+      } else if (file.handle === undefined) {
+        problems.push(t('attachmentUnreadable', { name: file.name, value: file.error ?? '' }));
+      } else if (attached.length >= MAX_ATTACHMENTS) {
+        over.push(file.handle);
+      } else {
+        attached.push({
+          handle: file.handle,
+          name: file.name,
+          bytes: file.bytes,
+          digest: file.digest,
+          id: null,
+          state: 'ready',
+          error: null,
+        });
+      }
+    }
+    if (over.length > 0) {
+      release(over);
+      problems.push(t('attachmentLimit', { limit: MAX_ATTACHMENTS }));
+    }
+    this.attached.set(transcript?.id ?? '', attached);
+    log(`composer: ${attached.length} file(s) attached, ${problems.length} problem(s)`);
+    if (problems.length > 0) this.hooks.report(problems.join(' '));
+    this.hooks.changed();
+  }
+
+  /** Takes a file off the draft for `transcript` and releases it. */
+  detach(transcript, handle) {
+    if (this.sending) return;
+    const kept = this.attachments(transcript).filter((file) => file.handle !== handle);
+    this.attached.set(transcript?.id ?? '', kept);
+    release([handle]);
+    this.hooks.changed();
+  }
+
+  /**
+   * Uploads each of `files` that `transcript`'s session does not have yet,
+   * one at a time. An uploaded file keeps its id, so a later try sends it as
+   * it is. Resolves to whether all of them are uploaded; one that is not (the
+   * Host refused it, or it could not be read or sent) is marked failed and
+   * reported, and the rest wait for the next try.
+   */
+  async upload(transcript, files) {
+    for (const file of files) {
+      if (file.id !== null) continue;
+      file.state = 'uploading';
+      file.error = null;
+      this.hooks.changed();
+      try {
+        const result = await operation(
+          transcript.endpoint,
+          'uploadAttachment',
+          { sessionId: transcript.id, name: file.name, bytes: file.bytes, digest: file.digest },
+          { file: { file: file.handle } },
+        );
+        if (result.kind === 'rejected') {
+          const reason = Object.hasOwn(REJECTIONS, result.reason)
+            ? t(REJECTIONS[result.reason])
+            : JSON.stringify(result.reason);
+          log(`composer: upload of ${file.name} rejected: ${JSON.stringify(result.reason)}`);
+          file.state = 'failed';
+          file.error = reason;
+          this.hooks.report(t('attachmentRejected', { name: file.name, reason }));
+          return false;
+        }
+        file.id = result.attachment.id;
+        file.state = 'ready';
+        log(`composer: uploaded ${file.name} as ${file.id}`);
+      } catch (error) {
+        file.state = 'failed';
+        file.error = error.message;
+        this.hooks.report(t('attachmentUploadFailed', { name: file.name, value: error.message }));
+        return false;
+      } finally {
+        this.hooks.changed();
+      }
+    }
+    return true;
+  }
+
+  /**
+   * The permission level `transcript`'s agent runs with, or the one a new
+   * task starts with: the level picked for it, else `defaultAccess`, the
+   * Host's default as far as it is known (null when it is not).
+   */
+  access(transcript, defaultAccess) {
+    if (transcript !== null) return transcript.session.permissions?.access ?? null;
+    return this.newAccess ?? defaultAccess;
+  }
+
+  /**
+   * Sets how much `transcript`'s agent may do before it asks, as the TUI's
+   * sandbox picker does: `agent.metadata` with the session's own title and
+   * labels (so an automatic title stays automatic) and the approvals granted
+   * so far revoked. A stale revision is read again once. With no transcript,
+   * it picks the level the next new task starts with. Resolves to whether the
+   * level is now `access`; a failure has been reported.
+   */
+  async setAccess(transcript, access) {
+    this.accessOpen = false;
+    if (transcript === null) {
+      this.newAccess = access;
+      this.hooks.changed();
+      return true;
+    }
+    if (transcript.session.permissions?.access === access) {
+      this.hooks.changed();
+      return true;
+    }
+    if (this.changingAccess) return false;
+    this.changingAccess = true;
+    this.hooks.changed();
+    try {
+      let session = transcript.session;
+      for (let attempt = 0; ; attempt += 1) {
+        const result = await operation(transcript.endpoint, 'metadata', {
+          sessionId: session.id,
+          expectedRevision: session.revision,
+          title: session.title,
+          labels: [...session.labels],
+          permissions: { settings: { ...session.permissions, access }, revokeGrants: true },
+        });
+        if (result.kind === 'committed') {
+          transcript.session = result.session;
+          log(`composer: ${session.id} runs with ${access}`);
+          return true;
+        }
+        if (result.kind === 'rejected') {
+          throw new Error(t('hostRefused', { value: JSON.stringify(result.reason) }));
+        }
+        if (attempt > 0) throw new Error(t('taskChangedMeanwhile'));
+        session = (await query(transcript.endpoint, 'observe', { sessionId: session.id })).session;
+      }
+    } catch (error) {
+      this.hooks.report(t('permissionFailed', { value: error.message }));
+      return false;
+    } finally {
+      this.changingAccess = false;
+      this.hooks.changed();
+    }
+  }
+
+  /** Picks the model the next message in `transcript` (or a new task) is sent with. */
+  choose(transcript, choice) {
+    this.chosen.set(transcript?.id ?? '', choice);
+    this.menuOpen = false;
+    log(`composer: chose ${choiceLabel(choice)}`);
+    this.hooks.changed();
+  }
+
+  /**
+   * Reads the models the Host offers. A session without a model of its own
+   * uses the configured default, or the first choice when there is none.
+   */
+  async loadModel() {
+    try {
+      const models = await readModels();
+      if (models === null) throw new Error(t('modelListChangingReopen'));
+      this.choices = models.choices;
+      const fallback =
+        models.defaultModel.kind === 'ready' ? models.defaultModel.choice : models.choices[0];
+      this.model = fallback?.selection ?? null;
+      this.modelLabel = fallback ? choiceLabel(fallback) : null;
+      // No model yet is not an error: the start pane offers Settings.
+      log(`composer: default model ${this.modelLabel ?? 'none'}`);
+      // A model chosen in the menu is kept as the Host offers it now, with
+      // its connection's current revision; one whose connection was removed
+      // is dropped, and the task's own model or the default applies again.
+      for (const [id, choice] of this.chosen) {
+        const current = await this.offered(choice.selection);
+        if (current !== null) this.chosen.set(id, current);
+        else {
+          this.chosen.delete(id);
+          log(`composer: ${choiceLabel(choice)} is not offered any more`);
+        }
+      }
+    } catch (error) {
+      this.hooks.report(t('modelsFailed', { value: error.message }));
+    } finally {
+      this.loaded = true;
+    }
+    this.hooks.changed();
+  }
+
+  /** What the footer names for `transcript`: its own model when it has one (as the TUI picks it). */
+  label(transcript) {
+    const chosen = this.chosen.get(transcript?.id ?? '');
+    if (chosen) return choiceLabel(chosen);
+    const backend = transcript?.backend ?? null;
+    if (backend?.kind === 'executor') return t('executorModel');
+    if (backend?.kind !== 'model') return this.modelLabel ?? t('noModel');
+    const choice = this.choices.find((c) => sameModel(c.selection, backend));
+    return choice ? choiceLabel(choice) : backend.modelId;
+  }
+
+  /**
+   * The `model` and `thinkingLevel` to send with: the session's own backend
+   * (`agent.observe`, as agent-screen/actions.ts reads it), else the default.
+   * The backend has no connection revision, so its choice is looked up.
+   */
+  async selection(transcript) {
+    const chosen = this.chosen.get(transcript.id);
+    if (chosen) {
+      const current = await this.offered(chosen.selection);
+      if (current === null) {
+        this.chosen.delete(transcript.id);
+        throw new Error(t('modelNotOffered', { model: choiceLabel(chosen) }));
+      }
+      return { model: current.selection, thinkingLevel: null };
+    }
+    const backend = transcript.backend;
+    if (backend?.kind === 'executor') {
+      throw new Error(t('executorCannotStart'));
+    }
+    if (backend?.kind !== 'model') {
+      if (this.model === null) throw new Error(t('noModelConfigured'));
+      return { model: this.model, thinkingLevel: null };
+    }
+    const choice = await this.offered(backend);
+    if (!choice) throw new Error(t('sessionModelNotOffered', { model: backend.modelId }));
+    return { model: choice.selection, thinkingLevel: backend.thinkingLevel ?? null };
+  }
+
+  /**
+   * The choice the Host offers now for a model (a connection and a model
+   * id), with its connection's current revision, or null when it offers none.
+   */
+  async offered(model) {
+    const models = await readModels(model.modelId);
+    if (models === null) throw new Error(t('modelListChanging'));
+    return models.choices.find((choice) => sameModel(choice.selection, model)) ?? null;
+  }
+
+  /**
+   * Sends the draft and its files to the transcript's session, starting a
+   * session first when none is selected. The files are uploaded first; the
+   * message goes only when all of them are. The draft is cleared only once
+   * the Host accepted it, and its text only if it was not edited meanwhile.
+   * With `again`, sends that text instead (a failed turn's message), without
+   * files, and leaves the draft alone.
+   */
+  async send(transcript, again = null) {
+    const message = again ?? this.draft.value();
+    const files = again === null ? this.attachments(transcript) : [];
+    if (this.sending || this.model === null || (message.trim() === '' && files.length === 0)) {
+      return;
+    }
+    this.sending = true;
+    this.hooks.changed();
+    try {
+      if (transcript === null) {
+        transcript = await this.start();
+        // Starting it failed; the app has said why.
+        if (transcript === null) return;
+        // A model chosen for the new task stays with the task.
+        const chosen = this.chosen.get('');
+        if (chosen) {
+          this.chosen.set(transcript.id, chosen);
+          this.chosen.delete('');
+        }
+        // So do its files, uploaded to the new task below.
+        if (files.length > 0) {
+          this.attached.set(transcript.id, files);
+          this.attached.delete('');
+        }
+        // So does a permission level, set before the first message runs; the
+        // next new task starts from the Host's default again. When it cannot
+        // be set, the message is not sent with a level the user did not pick.
+        if (this.newAccess !== null) {
+          const access = this.newAccess;
+          this.newAccess = null;
+          if (!(await this.setAccess(transcript, access))) return;
+        }
+      }
+      const { model, thinkingLevel } = await this.selection(transcript);
+      if (!(await this.upload(transcript, files))) return;
+      const result = await operation(transcript.endpoint, 'send', {
+        sessionId: transcript.id,
+        message,
+        model,
+        ...(files.length > 0 ? { attachments: files.map((file) => file.id) } : {}),
+        // Left out, the model's default applies; an explicit level is sent only when chosen.
+        ...(thinkingLevel !== null ? { thinkingLevel } : {}),
+      });
+      log(
+        `composer: send ${result.kind} ${result.invocationId ?? ''} message ${result.messageId ?? ''}`,
+      );
+      if (result.kind === 'rejected')
+        throw new Error(t('hostRejectedMessage', { value: result.reason }));
+      if (again === null && this.draft.value() === message) {
+        // `set_value` sends no change event, so the draft's emptiness is set here.
+        this.draft.set_value('');
+        this.empty = true;
+      }
+      // The files went with the message: the draft lets go of them.
+      if (files.length > 0) {
+        const kept = this.attachments(transcript).filter((file) => !files.includes(file));
+        this.attached.set(transcript.id, kept);
+        release(files.map((file) => file.handle));
+      }
+      // The run itself shows up through the watch (`opened`), as in the TUI.
+      transcript.addPending(
+        result.messageId,
+        message,
+        files.map((file) => ({ name: file.name, bytes: file.bytes })),
+      );
+    } catch (error) {
+      this.hooks.report(t('sendFailed', { value: error.message }));
+    } finally {
+      this.sending = false;
+      this.hooks.changed();
+    }
+  }
+
+  async stop(transcript) {
+    const run = transcript?.run;
+    if (!run || this.stopping) return;
+    this.stopping = true;
+    this.hooks.changed();
+    try {
+      const result = await operation(transcript.endpoint, 'stop', {
+        sessionId: transcript.id,
+        invocationId: run.invocationId,
+      });
+      log(`composer: stop requested=${result.requested}`);
+    } catch (error) {
+      this.hooks.report(t('stopFailed', { value: error.message }));
+    } finally {
+      this.stopping = false;
+      this.hooks.changed();
+    }
+  }
+}
+
+/**
+ * The composer under the transcript or the empty state, in the reading column.
+ * @param {Composer} composer
+ * @param {import("./transcript.js").Transcript | null} transcript
+ * @param {{ folder: { name: string, path: string } | null, picker: object | null,
+ *   defaultAccess: string | null, notice: string | null, dismiss: () => void,
+ *   addConnection: () => void }} context
+ *   `folder` is where a new task starts when no task is selected, and `picker`
+ *   the chip that chooses it; `defaultAccess` the permission level new tasks
+ *   get when known; `notice` is the last error
+ */
+export function composerPane(composer, transcript, context, cx) {
+  const m = palette(cx);
+  const dark = cx.theme().is_dark;
+  const inactive = transcript === null && context.folder === null;
+  const running = transcript?.run != null;
+  const executor = transcript?.backend?.kind === 'executor';
+  const files = composer.attachments(transcript);
+  const unsent = !composer.empty || files.length > 0;
+  const canSend = !inactive && !composer.sending && composer.model !== null && unsent;
+  const stop = running && !unsent;
+  return h_flex()
+    .flex_none()
+    .w_full()
+    .px(24)
+    .pt(8)
+    .pb(16)
+    .justify_center()
+    .child(
+      v_flex()
+        .w_full()
+        .max_w(720)
+        .rounded(28)
+        .bg(m.plate)
+        .border_1()
+        .border_color(dark ? m.border : m.dockRing)
+        .when(!dark, (el) => el.shadow_sm())
+        .child(
+          v_flex()
+            .p(12)
+            .gap(6)
+            .child(
+              typeset(Textarea.new(composer.draft), 'body')
+                .w_full()
+                .px(8)
+                .py(4)
+                .bg('#00000000')
+                .border_0()
+                .text_color(m.ink)
+                .disabled(inactive),
+            )
+            .when(files.length > 0, (el) =>
+              el.child(attachmentRow(composer, transcript, files, cx)),
+            )
+            .child(
+              h_flex()
+                .gap(2)
+                .items_center()
+                .child(
+                  attachButton(
+                    composer,
+                    transcript,
+                    !inactive && composer.canAttach(transcript),
+                    cx,
+                  ),
+                )
+                .child(modelMenu(composer, transcript, context, cx))
+                .child(permissionMenu(composer, transcript, context, cx))
+                .when(context.picker !== null, (el) => el.child(context.picker))
+                .child(note(context, executor, cx))
+                .child(
+                  stop
+                    ? actionButton('stop', 'stop', cx)
+                        .bg(m.ink)
+                        .disabled(composer.stopping)
+                        .tooltip(t('stop'))
+                        .hover((style) => style.opacity(0.84))
+                        .on_click(() => composer.stop(transcript))
+                    : actionButton('send', 'send', cx, canSend ? m.onPrimary : m.inkMuted)
+                        .bg(canSend ? m.primary : m.wash)
+                        .disabled(!canSend)
+                        .tooltip(t('send'))
+                        .when(canSend, (el) => el.hover((style) => style.bg(m.primaryHover)))
+                        .on_click(() => composer.send(transcript)),
+                ),
+            ),
+        ),
+    );
+}
+
+/** The "+" that attaches files to the draft, first in the controls row. */
+function attachButton(composer, transcript, enabled, cx) {
+  const m = palette(cx);
+  return ghostButton('attach-files', cx)
+    .size(28)
+    .justify_center()
+    .rounded(8)
+    .disabled(!enabled)
+    .when(!enabled, (el) => el.opacity(0.5))
+    .tooltip(t('attachFiles'))
+    .child(icon('plus', 16, m.ink))
+    .on_click(() => composer.chooseAttachments(transcript));
+}
+
+/** The files attached to the draft, above the controls row, each with a remove button. */
+function attachmentRow(composer, transcript, files, cx) {
+  return h_flex()
+    .flex_wrap()
+    .gap(8)
+    .children(
+      files.map((file) =>
+        fileChip(file, cx).child(
+          iconButton(`attachment-remove-${file.handle}`, 'close', cx, 20, 14)
+            .disabled(composer.sending)
+            .tooltip(t('remove'))
+            .on_click(() => composer.detach(transcript, file.handle)),
+        ),
+      ),
+    );
+}
+
+/** The 32px round Send or Stop button. */
+function actionButton(id, glyph, cx, color = null) {
+  const m = palette(cx);
+  return Button.new(id)
+    .flex_none()
+    .size(32)
+    .rounded(16)
+    .items_center()
+    .justify_center()
+    .child(icon(glyph, 16, color ?? m.plate));
+}
+
+/** A chip in the controls row: 28 high, label and a chevron. */
+function chip(id, label, cx) {
+  const m = palette(cx);
+  return typeset(ghostButton(id, cx), 'label')
+    .h(28)
+    .px(8)
+    .gap(4)
+    .max_w(260)
+    .child(div().min_w_0().truncate().child(label))
+    .child(icon('chevron-down', 14, m.inkMuted));
+}
+
+/** The model chip and the menu it opens upward: models by connection, then Add connection. */
+function modelMenu(composer, transcript, context, cx) {
+  const current = composer.label(transcript);
+  const chosen = composer.chosen.get(transcript?.id ?? '');
+  const selected =
+    chosen?.selection ??
+    (transcript?.backend?.kind === 'model' ? transcript.backend : composer.model);
+  const byConnection = new Map();
+  for (const choice of composer.choices) {
+    const name = choice.connectionName ?? '';
+    if (!byConnection.has(name)) byConnection.set(name, []);
+    byConnection.get(name).push(choice);
+  }
+  const rows = [];
+  for (const [name, choices] of byConnection) {
+    if (name !== '') rows.push(menuLabel(name, cx));
+    choices.forEach((choice, index) => {
+      rows.push(
+        menuRow(
+          `model-${slugOf(name)}-${index}`,
+          choice.label,
+          { checked: selected != null && sameModel(choice.selection, selected) },
+          cx,
+        ).on_click(() => composer.choose(transcript, choice)),
+      );
+    });
+  }
+  if (rows.length > 0) rows.push(menuSeparator(cx));
+  rows.push(
+    menuRow('model-add', t('addConnectionMenu'), { icon: 'plus' }, cx).on_click(() => {
+      composer.menuOpen = false;
+      context.addConnection();
+    }),
+  );
+  return Popover.new('model-menu')
+    .anchor('bottom_left')
+    .open(composer.menuOpen)
+    .on_open_change((open) => {
+      composer.menuOpen = open;
+      composer.hooks.changed();
+    })
+    .trigger(chip('model-chip', current.split(' · ')[0], cx).tooltip(current))
+    .content(
+      menuSurface(cx)
+        .id('model-menu-rows')
+        .w(280)
+        .max_h(360)
+        .overflow_y_scroll()
+        .mb(8)
+        .children(rows),
+    );
+}
+
+/**
+ * The permission levels, by the Host's access value, with the native
+ * client's names and one-line descriptions: Read only asks before writing or
+ * using the network, Auto works inside the workspace and asks before going
+ * further, Full access reaches files and the network directly.
+ */
+const ACCESS = [
+  ['read_only', 'permissionReadOnly', 'permissionReadOnlyHint'],
+  ['workspace_write', 'permissionAuto', 'permissionAutoHint'],
+  ['danger_full_access', 'permissionFullAccess', 'permissionFullAccessHint'],
+];
+
+/** The permission chip and its menu, which opens upward. */
+function permissionMenu(composer, transcript, context, cx) {
+  const current = composer.access(transcript, context.defaultAccess);
+  const name = ACCESS.find(([access]) => access === current)?.[1] ?? 'permissionMode';
+  return Popover.new('permission-menu')
+    .anchor('bottom_left')
+    .open(composer.accessOpen)
+    .on_open_change((open) => {
+      composer.accessOpen = open;
+      composer.hooks.changed();
+    })
+    .trigger(
+      chip('permission-chip', t(name), cx)
+        .disabled(composer.changingAccess)
+        .tooltip(t('permissionMode')),
+    )
+    .content(
+      menuSurface(cx)
+        .id('permission-menu-rows')
+        .w(340)
+        .mb(8)
+        .children(
+          ACCESS.map(([access, label, hint]) =>
+            accessRow(access, label, hint, access === current, cx).on_click(() =>
+              composer.setAccess(transcript, access),
+            ),
+          ),
+        ),
+    );
+}
+
+/** One level in the permission menu: the check, its name, and what it allows below. */
+function accessRow(access, label, hint, checked, cx) {
+  const m = palette(cx);
+  return Button.new(`permission-${access}`)
+    .w_full()
+    .px(8)
+    .py(6)
+    .gap(8)
+    .items_start()
+    .rounded(6)
+    .hover((style) => style.bg(m.activeRow))
+    .child(
+      div()
+        .w(16)
+        .pt(3)
+        .flex_none()
+        .when(checked, (el) => el.child(icon('check', 14, m.ink))),
+    )
+    .child(
+      v_flex()
+        .flex_1()
+        .min_w_0()
+        .gap(2)
+        .child(typeset(div(), 'body').text_color(m.ink).child(t(label)))
+        .child(text(t(hint), 'small', access === 'danger_full_access' ? m.warning : m.inkMuted)),
+    );
+}
+
+/** The note between the chips and Send: the last error, or why sending is not possible. */
+function note(context, executor, cx) {
+  const m = palette(cx);
+  const message = context.notice ?? (executor ? t('executorNote') : null);
+  return h_flex()
+    .id('composer-note')
+    .flex_1()
+    .min_w_0()
+    .px(8)
+    .when(message !== null, (el) =>
+      el
+        .tooltip(context.notice !== null ? t('noticeDismiss', { value: message }) : message)
+        .when(context.notice !== null, (el) =>
+          el.cursor_pointer().on_click(() => context.dismiss()),
+        )
+        .child(
+          text(message, 'small', context.notice !== null ? m.destructive : m.inkMuted).truncate(),
+        ),
+    );
+}
+
+function slugOf(name) {
+  return name.toLowerCase().replace(/[^a-z0-9]+/g, '-');
+}
+
+function choiceLabel(choice) {
+  return choice.connectionName ? `${choice.label} · ${choice.connectionName}` : choice.label;
+}
+
+function sameModel(selection, backend) {
+  return selection.connectionId === backend.connectionId && selection.modelId === backend.modelId;
+}
+
+/** A control button; `primary` fills it with the primary color (see theme.js controlButton). */
+export function button(id, label, primary, disabled, cx) {
+  return controlButton(id, label, primary ? 'primary' : 'outline', disabled, cx);
+}
diff --git a/products/maka/desktop-shell/app/directories.js b/products/maka/desktop-shell/app/directories.js
new file mode 100644
index 0000000000..edb4ccd45e
--- /dev/null
+++ b/products/maka/desktop-shell/app/directories.js
@@ -0,0 +1,345 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Where a new task runs, picked from the composer's folder chip. The menu
+// lists the Host's projects (the Host registers every folder a task starts
+// in) and its root directories. "Choose a folder…" shows the platform's
+// folder panel; the folder chosen there is found in the Host's listing by
+// walking `agent.directories` from the root that holds it, 32 children a
+// page, because a task starts from the Host's selection, not from a path.
+
+import { div } from 'gpui-kit';
+import { v_flex, Button, Popover } from 'gpui-base';
+import { chooseFolder } from 'maka-files';
+import { query, log } from './host.js';
+import {
+  palette,
+  typeset,
+  text,
+  icon,
+  ghostButton,
+  menuSurface,
+  menuRow,
+  menuSeparator,
+} from './theme.js';
+import { t } from './locale.js';
+
+/** Project pages read for the menu; the Host pages at most 16 projects. */
+const MAX_PROJECT_PAGES = 16;
+/** Children pages read for one folder while finding a chosen path. */
+const MAX_CHILD_PAGES = 64;
+
+export class Directories {
+  /** @param {{ changed: () => void, report: (message: string) => void }} hooks */
+  constructor(hooks) {
+    this.hooks = hooks;
+    this.roots = [];
+    /** The Host's projects that are not archived, as it lists them. */
+    this.projects = [];
+    /** Where a new task runs, `{ name, path, selection }`; null means the first root. */
+    this.chosen = null;
+    this.menuOpen = false;
+    /** Whether a folder from the panel is being found in the Host's listing. */
+    this.finding = false;
+    this.projectReads = 0;
+  }
+
+  /** Where a new task runs: the chosen folder, or the first root. */
+  target() {
+    return this.chosen ?? this.roots[0] ?? null;
+  }
+
+  /** Opens or closes the menu; opening reads the projects again. */
+  setMenuOpen(open) {
+    this.menuOpen = open;
+    if (open) this.loadProjects();
+    this.hooks.changed();
+  }
+
+  /** Makes `directory` where new tasks run, and closes the menu. */
+  choose(directory) {
+    this.chosen = { name: directory.name, path: directory.path, selection: directory.selection };
+    this.menuOpen = false;
+    this.hooks.changed();
+  }
+
+  /** Shows the folder panel and makes the folder chosen there where new tasks run. */
+  async chooseFromPanel() {
+    this.menuOpen = false;
+    this.hooks.changed();
+    let path;
+    try {
+      path = await chooseFolder({ prompt: t('choose') });
+    } catch (error) {
+      this.hooks.report(t('chooseFolderFailed', { value: error.message }));
+      return;
+    }
+    if (path === null) return;
+    await this.chooseByPath(path);
+  }
+
+  /** Finds `path` in the Host's folders and makes it where new tasks run. */
+  async chooseByPath(path) {
+    this.finding = true;
+    this.hooks.changed();
+    try {
+      this.choose(await this.find(path));
+      log(`directories: new tasks run in ${path}`);
+    } catch (error) {
+      this.hooks.report(t('chooseFolderFailed', { value: error.message }));
+    } finally {
+      this.finding = false;
+      this.hooks.changed();
+    }
+  }
+
+  /**
+   * The Host's directory at `path`: from the root that holds it, each
+   * folder's children are read until the next name on the path is found.
+   */
+  async find(path) {
+    const trimmed = path.length > 1 ? path.replace(/\/+$/, '') : path;
+    const root = this.roots
+      .filter(
+        (root) => trimmed === root.path || trimmed.startsWith(`${root.path.replace(/\/$/, '')}/`),
+      )
+      .sort((a, b) => b.path.length - a.path.length)[0];
+    if (root === undefined) {
+      const offered = this.roots.map((root) => root.path).join(', ');
+      throw new Error(
+        offered === ''
+          ? t('folderNotOfferedNone', { path: trimmed })
+          : t('folderNotOffered', { path: trimmed, offered }),
+      );
+    }
+    let directory = root;
+    const names = trimmed.slice(root.path.length).split('/').filter(Boolean);
+    for (const name of names) {
+      directory = await this.child(directory, name);
+    }
+    return directory;
+  }
+
+  /** The child of `directory` called `name`, reading its pages in order. */
+  async child(directory, name) {
+    let after = null;
+    for (let pages = 0; pages < MAX_CHILD_PAGES; pages += 1) {
+      const page = await query('agent', 'directories', {
+        kind: 'children',
+        selection: directory.selection,
+        after,
+      });
+      const found = (page.children ?? []).find((child) => child.name === name);
+      if (found !== undefined) return found;
+      after = page.after ?? null;
+      if (after === null) break;
+    }
+    throw new Error(t('hostDoesNotList', { name, path: directory.path }));
+  }
+
+  /**
+   * Reads every page of `agent.projects`. A page answered `changed` means
+   * the list moved between pages, so the read starts over once.
+   */
+  async loadProjects() {
+    const reading = ++this.projectReads;
+    try {
+      for (let attempt = 0; attempt < 2; attempt += 1) {
+        const projects = [];
+        let cursor = null;
+        let changed = false;
+        for (let pages = 0; pages < MAX_PROJECT_PAGES; pages += 1) {
+          const page = await query('agent', 'projects', { kind: 'list', cursor });
+          if (page.kind === 'changed') {
+            changed = true;
+            break;
+          }
+          if (page.kind !== 'projects') throw new Error(t('hostAnswered', { value: page.kind }));
+          projects.push(...page.projects);
+          cursor = page.next ?? null;
+          if (cursor === null) break;
+        }
+        if (changed) continue;
+        if (reading === this.projectReads) {
+          this.projects = projects.filter(
+            (project) => !project.archived && project.selection !== null,
+          );
+          this.hooks.changed();
+        }
+        return;
+      }
+      throw new Error(t('listKeptChanging'));
+    } catch (error) {
+      if (reading === this.projectReads) {
+        this.hooks.report(t('projectsFailed', { value: error.message }));
+      }
+    }
+  }
+
+  async loadRoots() {
+    try {
+      const page = await query('agent', 'directories', { kind: 'roots' });
+      this.roots = page.roots ?? [];
+    } catch (error) {
+      this.hooks.report(t('directoriesFailed', { value: error.message }));
+    }
+    this.hooks.changed();
+  }
+}
+
+/**
+ * `path` as the menu shows it: under a root the Host names `~`, from `~`.
+ * @param {{ name: string, path: string }[]} roots
+ */
+export function shortPath(path, roots) {
+  for (const root of roots) {
+    if (root.name !== '~') continue;
+    if (path === root.path) return '~';
+    if (path.startsWith(`${root.path}/`)) return `~${path.slice(root.path.length)}`;
+  }
+  return path;
+}
+
+/**
+ * The places the menu offers, most recently used first: the projects, by
+ * their newest task in `sessions`, then the roots no project already names.
+ * A project in a root's own folder goes by the root's name, as the chip
+ * calls the first root before anything is chosen.
+ */
+export function places(directories, sessions) {
+  const latest = new Map();
+  for (const session of sessions) {
+    const path = session.workspace?.path;
+    if (typeof path === 'string' && !(session.updatedAt <= (latest.get(path) ?? -Infinity))) {
+      latest.set(path, session.updatedAt);
+    }
+  }
+  const rootNames = new Map(directories.roots.map((root) => [root.path, root.name]));
+  const projects = directories.projects
+    .map((project) => ({
+      name: rootNames.get(project.path) ?? project.name,
+      path: project.path ?? '',
+      selection: project.selection,
+      available: project.available,
+    }))
+    .sort(
+      (a, b) =>
+        (latest.get(b.path) ?? -Infinity) - (latest.get(a.path) ?? -Infinity) ||
+        a.name.localeCompare(b.name),
+    );
+  const named = new Set(projects.map((place) => place.path));
+  const roots = directories.roots
+    .filter((root) => !named.has(root.path))
+    .map((root) => ({ ...root, available: true }));
+  return [...projects, ...roots];
+}
+
+/**
+ * The composer's folder chip for a new task, and the menu it opens upward:
+ * the places a task can run, then "Choose a folder…", which shows the
+ * platform's folder panel.
+ * @param {Directories} directories
+ * @param {object[]} sessions the catalog's sessions, to put recent places first
+ * @param {boolean} disabled
+ */
+export function folderPicker(directories, sessions, disabled, cx) {
+  const m = palette(cx);
+  const target = directories.target();
+  // While a folder from the panel is being found, the chip waits for it.
+  disabled = disabled || directories.finding;
+  const chip = typeset(ghostButton('folder-chip', cx), 'label')
+    .flex_none()
+    .h(28)
+    .px(8)
+    .gap(4)
+    .max_w(260)
+    .disabled(disabled)
+    .tooltip(target?.path ?? t('chooseFolder'))
+    .child(icon('folder', 14, m.inkMuted))
+    .child(
+      div()
+        .min_w_0()
+        .truncate()
+        .child(target?.name || target?.path || t('chooseFolder')),
+    )
+    .child(icon('chevron-down', 14, m.inkMuted));
+  if (disabled) return chip;
+  return Popover.new('folder-menu')
+    .anchor('bottom_left')
+    .open(directories.menuOpen)
+    .on_open_change((open) => directories.setMenuOpen(open))
+    .trigger(chip)
+    .content(
+      menuSurface(cx)
+        .w(340)
+        .mb(8)
+        .children(placeRows(directories, sessions, cx)),
+    );
+}
+
+function placeRows(directories, sessions, cx) {
+  const target = directories.target();
+  const rows = places(directories, sessions).map((place, index) =>
+    placeRow(`place-${index}`, place, place.path === target?.path, directories.roots, cx).on_click(
+      () => directories.choose(place),
+    ),
+  );
+  return [
+    v_flex().id('folder-places').max_h(320).overflow_y_scroll().children(rows),
+    ...(rows.length > 0 ? [menuSeparator(cx)] : []),
+    menuRow('folder-browse', t('chooseFolderMenu'), { icon: 'folder-open' }, cx).on_click(() =>
+      directories.chooseFromPanel(),
+    ),
+  ];
+}
+
+/**
+ * A place in the menu: its name over its folder, checked when new tasks run
+ * there. The folder is written from `~` unless that would only repeat the name.
+ */
+function placeRow(id, place, checked, roots, cx) {
+  const m = palette(cx);
+  const short = shortPath(place.path, roots);
+  const folder = short === (place.name || place.path) ? place.path : short;
+  return typeset(Button.new(id), 'body')
+    .w_full()
+    .px(8)
+    .py(6)
+    .gap(8)
+    .items_center()
+    .rounded(6)
+    .disabled(!place.available)
+    .when(place.available, (el) => el.hover((style) => style.bg(m.activeRow)))
+    .child(icon('folder', 16, m.inkMuted))
+    .child(
+      v_flex()
+        .flex_1()
+        .min_w_0()
+        .child(
+          div()
+            .truncate()
+            .text_color(place.available ? m.ink : m.inkDisabled)
+            .child(place.name || place.path),
+        )
+        .child(
+          text(place.available ? folder : t('folderNotFound'), 'small', m.inkMuted).truncate(),
+        ),
+    )
+    .when(checked, (el) => el.child(icon('check', 16, m.ink)));
+}
diff --git a/products/maka/desktop-shell/app/gpui-shell.json b/products/maka/desktop-shell/app/gpui-shell.json
new file mode 100644
index 0000000000..d368a68d04
--- /dev/null
+++ b/products/maka/desktop-shell/app/gpui-shell.json
@@ -0,0 +1,12 @@
+{
+  "id": "org.apache.maka.desktop",
+  "name": "Maka",
+  "version": "0.1.0",
+  "shell-version": "0.7.3",
+  "entry": "main.js",
+  "window": { "width": 1512, "height": 885 },
+  "capabilities": {
+    "storage": true,
+    "clipboard": { "write": true }
+  }
+}
diff --git a/products/maka/desktop-shell/app/host.js b/products/maka/desktop-shell/app/host.js
new file mode 100644
index 0000000000..3a1bc0af77
--- /dev/null
+++ b/products/maka/desktop-shell/app/host.js
@@ -0,0 +1,112 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Thin wrappers over the `maka-host` module, and the debug log. Every Host
+// call the application makes goes through this file.
+
+import { query, mutate, subscribe, next, close } from 'maka-host';
+
+export { query, subscribe, next, close };
+
+let debugging = false;
+const started = Date.now();
+
+/** Turns the debug log on; `dev.js` beside `main.js` does this. Off by default. */
+export function setDebug(enabled) {
+  debugging = enabled;
+}
+
+/** Writes one line to stderr when debugging, with milliseconds since start. */
+export function log(message) {
+  if (debugging) console.log(`maka-desktop +${Date.now() - started}ms ${message}`);
+}
+
+/**
+ * Runs a mutation and returns the method's own result. `mutate` resolves with
+ * the operation reply; the result is the receipt's returned value. Any other
+ * receipt is an error. A mutation is never retried: only the original call
+ * can tell whether it ran. `attachments` maps resource slots to text, such as
+ * the `secret` of `settings.saveCredential`, or to `{ file: handle }`, a file
+ * `maka-files` registered, such as the `file` of `agent.uploadAttachment`; it
+ * never reaches the log.
+ *
+ * @param {string} endpoint
+ * @param {string} method
+ * @param {unknown} input
+ * @param {Record<string, string | { file: string }>} [attachments]
+ */
+export async function operation(endpoint, method, input, attachments) {
+  const reply = await mutate(endpoint, method, input, attachments);
+  const receipt = reply?.receipt;
+  if (receipt?.kind !== 'recorded' || receipt.outcome?.kind !== 'returned') {
+    throw new Error(
+      `${endpoint}.${method} was not recorded as returned: ${JSON.stringify(receipt).slice(0, 300)}`,
+    );
+  }
+  return receipt.outcome.value;
+}
+
+/**
+ * Every model the Host offers that matches `search`, read page by page:
+ * `{ choices, defaultModel }`. When the list changes between pages it is read
+ * again from the start, once; null when it changed again.
+ */
+export async function readModels(search = '') {
+  for (let attempt = 0; attempt < 2; attempt += 1) {
+    let page = await query('settings', 'models', { kind: 'start', query: search });
+    if (page.kind !== 'ready') continue;
+    const { revision, defaultModel } = page;
+    const choices = [...page.choices];
+    while (page.kind === 'ready' && (page.next ?? null) !== null) {
+      page = await query('settings', 'models', { kind: 'continue', revision, cursor: page.next });
+      if (page.kind === 'ready') choices.push(...page.choices);
+    }
+    if (page.kind === 'ready') return { choices, defaultModel };
+  }
+  return null;
+}
+
+/**
+ * Reads one `history` page of an Agent endpoint (`agent`, or WorkHub's
+ * `coordinator`), asking again with the same fence and position while the
+ * Host answers `preparing` (as the TUI does,
+ * plugins/agent/agent-screen/results.ts:705). Resolves with the ready page and
+ * the number of calls it took.
+ */
+export async function readHistory(input, endpoint = 'agent') {
+  for (let calls = 1; ; calls += 1) {
+    const page = await query(endpoint, 'history', input);
+    if (input.through !== undefined && page.through !== input.through) {
+      throw new Error(`history answered fence ${page.through} for ${input.through}`);
+    }
+    if (page.kind === 'ready') return { page, calls };
+  }
+}
+
+/**
+ * Reads one excerpt of a transcript field with an Agent endpoint's `source`,
+ * asking again while the Host answers `preparing` (as the TUI's tool details
+ * do, plugins/agent/tool-details/index.ts:156-180). Resolves with the source.
+ */
+export async function readSource(input, endpoint = 'agent') {
+  for (;;) {
+    const page = await query(endpoint, 'source', input);
+    if (page.kind === 'ready') return page.source;
+  }
+}
diff --git a/products/maka/desktop-shell/app/icons/activity.svg b/products/maka/desktop-shell/app/icons/activity.svg
new file mode 100644
index 0000000000..4849410d20
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/activity.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.25 8h2.25l1.75-4.75 3.5 9.5L11.5 8h2.25"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/archive.svg b/products/maka/desktop-shell/app/icons/archive.svg
new file mode 100644
index 0000000000..2d0d898c20
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/archive.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2.75" width="12" height="3.25" rx="1"/><path d="M3.25 6v6.25A1.5 1.5 0 0 0 4.75 13.75h6.5a1.5 1.5 0 0 0 1.5-1.5V6"/><path d="M6.5 9h3"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/attach.svg b/products/maka/desktop-shell/app/icons/attach.svg
new file mode 100644
index 0000000000..2778ab0047
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/attach.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m13 7.6-4.95 4.95a3.2 3.2 0 0 1-4.53-4.53l5.3-5.3a2.13 2.13 0 0 1 3.02 3.02L6.6 11a1.07 1.07 0 0 1-1.51-1.51l4.6-4.6"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/check.svg b/products/maka/desktop-shell/app/icons/check.svg
new file mode 100644
index 0000000000..e5502fb555
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/check.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3.25 8.25 6.5 11.5l6.25-7"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/chevron-down.svg b/products/maka/desktop-shell/app/icons/chevron-down.svg
new file mode 100644
index 0000000000..2bdc0c840a
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/chevron-down.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m4.5 6.25 3.5 3.5 3.5-3.5"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/chevron-left.svg b/products/maka/desktop-shell/app/icons/chevron-left.svg
new file mode 100644
index 0000000000..baa74100db
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/chevron-left.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 3.5 5.5 8l4.5 4.5"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/chevron-right.svg b/products/maka/desktop-shell/app/icons/chevron-right.svg
new file mode 100644
index 0000000000..9dc2efaba4
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/chevron-right.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3.5 10.5 8 6 12.5"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/close.svg b/products/maka/desktop-shell/app/icons/close.svg
new file mode 100644
index 0000000000..90648dfc7a
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/close.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m4.25 4.25 7.5 7.5M11.75 4.25l-7.5 7.5"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/compose.svg b/products/maka/desktop-shell/app/icons/compose.svg
new file mode 100644
index 0000000000..b550d2b2ba
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/compose.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13.5 8.5V12a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 12V4A1.5 1.5 0 0 1 4 2.5h3.5"/><path d="M12.1 2.4a1.41 1.41 0 0 1 2 2L9 9.5l-2.5.5.5-2.5z"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/copy.svg b/products/maka/desktop-shell/app/icons/copy.svg
new file mode 100644
index 0000000000..e3a85e2f2c
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/copy.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5"/><path d="M10.5 5.5V4A1.5 1.5 0 0 0 9 2.5H4A1.5 1.5 0 0 0 2.5 4v5A1.5 1.5 0 0 0 4 10.5h1.5"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/cpu.svg b/products/maka/desktop-shell/app/icons/cpu.svg
new file mode 100644
index 0000000000..b448cd6bad
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/cpu.svg
@@ -0,0 +1,19 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="8" height="8" rx="1.5"/><rect x="6.5" y="6.5" width="3" height="3" rx="0.5"/><path d="M6.5 1.75v2.25M9.5 1.75v2.25M6.5 12v2.25M9.5 12v2.25M1.75 6.5h2.25M1.75 9.5h2.25M12 6.5h2.25M12 9.5h2.25"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/file-diff.svg b/products/maka/desktop-shell/app/icons/file-diff.svg
new file mode 100644
index 0000000000..00ff160a2f
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/file-diff.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 2H5a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 5 14h6a1.5 1.5 0 0 0 1.5-1.5V5z"/><path d="M6 6.5h4"/><path d="M8 4.5v4"/><path d="M6 11h4"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/flag.svg b/products/maka/desktop-shell/app/icons/flag.svg
new file mode 100644
index 0000000000..e3d014b2ee
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/flag.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 14V2.75"/><path d="M3.5 3h7.75l-1.75 2.75 1.75 2.75H3.5"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/folder-open.svg b/products/maka/desktop-shell/app/icons/folder-open.svg
new file mode 100644
index 0000000000..65ef935ef1
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/folder-open.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 11.5v-7A1.5 1.5 0 0 1 4 3h2.4L8 4.75h3.5A1.5 1.5 0 0 1 13 6.25V7.5"/><path d="M2.5 11.5l1.6-3.17A1.5 1.5 0 0 1 5.44 7.5h8.06a.75.75 0 0 1 .7 1.01l-1.28 3.5a1.5 1.5 0 0 1-1.41.99H4a1.5 1.5 0 0 1-1.5-1.5z"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/folder.svg b/products/maka/desktop-shell/app/icons/folder.svg
new file mode 100644
index 0000000000..12ca9588f6
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/folder.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 4.5A1.5 1.5 0 0 1 4 3h2.4L8 4.75h4a1.5 1.5 0 0 1 1.5 1.5v5.25A1.5 1.5 0 0 1 12 13H4a1.5 1.5 0 0 1-1.5-1.5z"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/host.svg b/products/maka/desktop-shell/app/icons/host.svg
new file mode 100644
index 0000000000..fc9eb5cd35
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/host.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="2.5" width="11" height="4.75" rx="1.25"/><rect x="2.5" y="8.75" width="11" height="4.75" rx="1.25"/><path d="M5 4.875h.01M5 11.125h.01" stroke-width="2"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/more.svg b/products/maka/desktop-shell/app/icons/more.svg
new file mode 100644
index 0000000000..98cb7165dd
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/more.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="3.5" cy="8" r="1" fill="currentColor" stroke="none"/><circle cx="8" cy="8" r="1" fill="currentColor" stroke="none"/><circle cx="12.5" cy="8" r="1" fill="currentColor" stroke="none"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/plus.svg b/products/maka/desktop-shell/app/icons/plus.svg
new file mode 100644
index 0000000000..a4c58d638f
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/plus.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3.5v9M3.5 8h9"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/queue.svg b/products/maka/desktop-shell/app/icons/queue.svg
new file mode 100644
index 0000000000..327651396e
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/queue.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.75 4h10.5M2.75 8h10.5M2.75 12h6"/><path d="m11.5 10.5 1.75 1.5-1.75 1.5"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/search.svg b/products/maka/desktop-shell/app/icons/search.svg
new file mode 100644
index 0000000000..11cee9f570
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/search.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="7" cy="7" r="4.5"/><path d="m13.5 13.5-3.25-3.25"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/send.svg b/products/maka/desktop-shell/app/icons/send.svg
new file mode 100644
index 0000000000..402e15232c
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/send.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 12.75V3.75M4.25 7.5 8 3.75l3.75 3.75" stroke-width="1.75"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/settings.svg b/products/maka/desktop-shell/app/icons/settings.svg
new file mode 100644
index 0000000000..d6a8b73df0
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/settings.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 4.75h6M11.5 4.75h2M2.5 11.25h2M7.5 11.25h6"/><circle cx="10" cy="4.75" r="1.5"/><circle cx="6" cy="11.25" r="1.5"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/sidebar.svg b/products/maka/desktop-shell/app/icons/sidebar.svg
new file mode 100644
index 0000000000..09eca3330e
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/sidebar.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2.25" y="2.75" width="11.5" height="10.5" rx="2"/><path d="M6.25 2.75v10.5"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/status-done.svg b/products/maka/desktop-shell/app/icons/status-done.svg
new file mode 100644
index 0000000000..ffa352d5cf
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/status-done.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="5.75"/><path d="m5.5 8.25 1.75 1.75 3.25-3.5"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/status-failed.svg b/products/maka/desktop-shell/app/icons/status-failed.svg
new file mode 100644
index 0000000000..5508ad97ff
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/status-failed.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="5.75"/><path d="m6.1 6.1 3.8 3.8M9.9 6.1l-3.8 3.8"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/status-running.svg b/products/maka/desktop-shell/app/icons/status-running.svg
new file mode 100644
index 0000000000..bbce8788d7
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/status-running.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="5.75" opacity=".25"/><path d="M8 2.25a5.75 5.75 0 0 1 5.75 5.75"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/status-stopped.svg b/products/maka/desktop-shell/app/icons/status-stopped.svg
new file mode 100644
index 0000000000..c38ff179b0
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/status-stopped.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="5.75"/><rect x="6" y="6" width="4" height="4" rx=".75" fill="currentColor" stroke="none"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/status-waiting.svg b/products/maka/desktop-shell/app/icons/status-waiting.svg
new file mode 100644
index 0000000000..697fc754b2
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/status-waiting.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="5.75"/><path d="M6.35 6.45a1.7 1.7 0 1 1 2.4 1.55c-.45.22-.75.55-.75 1.05v.2"/><path d="M8 10.85h.01" stroke-width="2"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/stop.svg b/products/maka/desktop-shell/app/icons/stop.svg
new file mode 100644
index 0000000000..a81c76f6b4
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/stop.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4.5" y="4.5" width="7" height="7" rx="1.75" fill="currentColor" stroke="none"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/tool-edit.svg b/products/maka/desktop-shell/app/icons/tool-edit.svg
new file mode 100644
index 0000000000..b3695b5e67
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/tool-edit.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12.5 6.75V5L9.5 2H5a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 5 14h1.75"/><path d="M9.5 2v3h3"/><path d="M12.65 8.85a1.06 1.06 0 0 1 1.5 1.5L10.5 14l-1.75.5.5-1.75z"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/tool-plug.svg b/products/maka/desktop-shell/app/icons/tool-plug.svg
new file mode 100644
index 0000000000..abeee34fbe
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/tool-plug.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2.5v3M10 2.5v3"/><path d="M4.25 5.5h7.5V8a3.75 3.75 0 0 1-7.5 0z"/><path d="M8 11.75v1.75"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/tool-read.svg b/products/maka/desktop-shell/app/icons/tool-read.svg
new file mode 100644
index 0000000000..13efdfb638
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/tool-read.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 2H5a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 5 14h6a1.5 1.5 0 0 0 1.5-1.5V5z"/><path d="M9.5 2v3h3"/><path d="M6 8.5h4M6 11h2.5"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/tool-search.svg b/products/maka/desktop-shell/app/icons/tool-search.svg
new file mode 100644
index 0000000000..dadda1aeb8
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/tool-search.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 3.75h6M2.5 7.25h3.5M2.5 10.75h3"/><circle cx="10.25" cy="9.25" r="2.75"/><path d="m12.25 11.25 1.75 1.75"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/tool-terminal.svg b/products/maka/desktop-shell/app/icons/tool-terminal.svg
new file mode 100644
index 0000000000..4bb4b9c1eb
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/tool-terminal.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2.75" width="12" height="10.5" rx="2"/><path d="m5 6.25 2 1.75-2 1.75"/><path d="M8.75 10h2.5"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/tool-web.svg b/products/maka/desktop-shell/app/icons/tool-web.svg
new file mode 100644
index 0000000000..394ac9b0ef
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/tool-web.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="5.75"/><path d="M2.25 8h11.5"/><path d="M8 2.25c1.55 1.65 2.35 3.55 2.35 5.75S9.55 12.1 8 13.75C6.45 12.1 5.65 10.2 5.65 8S6.45 3.9 8 2.25z"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/triangle-alert.svg b/products/maka/desktop-shell/app/icons/triangle-alert.svg
new file mode 100644
index 0000000000..5a73c7394c
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/triangle-alert.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7.13 2.5a1 1 0 0 1 1.74 0l5.5 9.75a1 1 0 0 1-.87 1.5H2.5a1 1 0 0 1-.87-1.5Z"/><path d="M8 6.25v3"/><path d="M8 11.5h.01"/></svg>
diff --git a/products/maka/desktop-shell/app/icons/wordmark.svg b/products/maka/desktop-shell/app/icons/wordmark.svg
new file mode 100644
index 0000000000..76439232fc
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/wordmark.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 460 120"><g transform="translate(0,120) scale(0.1,-0.1)" fill="currentColor" stroke="none"><path d="M2639 1187 c-38 -29 -39 -46 -39 -479 0 -400 1 -425 19 -455 23 -37 68 -50 108 -31 41 20 53 53 53 154 0 83 2 92 25 114 l24 23 143 -138 c79 -75 156 -143 171 -151 57 -30 127 14 127 79 0 32 -39 75 -217 238 l-82 75 130 103 c157 125 173 152 123 210 -21 25 -34 31 -65 31 -35 0 -55 -13 -209 -140 l-170 -139 0 229 0 228 -26 31 c-20 24 -34 31 -62 31  [...]
diff --git a/products/maka/desktop-shell/app/icons/workhub.svg b/products/maka/desktop-shell/app/icons/workhub.svg
new file mode 100644
index 0000000000..de587de3ad
--- /dev/null
+++ b/products/maka/desktop-shell/app/icons/workhub.svg
@@ -0,0 +1,20 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8.75" r="1.75"/><circle cx="8" cy="3" r="1.5"/><circle cx="3.25" cy="12.25" r="1.5"/><circle cx="12.75" cy="12.25" r="1.5"/><path d="M8 7V4.5M6.6 9.8l-2.15 1.55M9.4 9.8l2.15 1.55"/></svg>
diff --git a/products/maka/desktop-shell/app/interactions.js b/products/maka/desktop-shell/app/interactions.js
new file mode 100644
index 0000000000..5928af3a4b
--- /dev/null
+++ b/products/maka/desktop-shell/app/interactions.js
@@ -0,0 +1,889 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// What the agent is waiting on in one session: service approvals, permission
+// requests, client capabilities and questions, and the card that answers
+// them. The watch delivers the pending list, `agent.interaction` reads each
+// request once and `agent.respond` answers it, with the answer shapes the TUI
+// sends (plugins/agent/agent-screen/component-events.ts, actions.ts
+// respondInteraction and submitQuestion, and plugins/agent/form-editor).
+
+import { div } from 'gpui-kit';
+import { v_flex, h_flex, InputState } from 'gpui-base';
+import { query, operation, log } from './host.js';
+import { palette, text, icon, controlButton, textField } from './theme.js';
+import { t, tn, onLanguageChange } from './locale.js';
+
+/** The scopes a permission can be allowed for, with the message that labels each. */
+const SCOPES = [
+  ['once', 'allowOnce'],
+  ['turn', 'allowForTurn'],
+  ['session', 'allowForSession'],
+];
+
+export class Interactions {
+  /**
+   * @param {string} sessionId
+   * @param {{ changed: () => void, report: (message: string) => void }} hooks
+   * @param {string} endpoint the session's Agent endpoint, as its transcript has it
+   */
+  constructor(sessionId, hooks, endpoint = 'agent') {
+    this.sessionId = sessionId;
+    this.hooks = hooks;
+    this.endpoint = endpoint;
+    /** The pending entries as the Host last listed them: { requestId, kind, toolUseId }. */
+    this.entries = [];
+    /** requestId -> the request's record, or null while it is being read. */
+    this.records = new Map();
+    /** requestId -> the answers chosen so far for a question with several parts. */
+    this.drafts = new Map();
+    /** requestId -> why reading it failed; the card offers to read it again. */
+    this.failures = new Map();
+    /** requestId -> the form being filled in, for a form request. */
+    this.forms = new Map();
+    /** The request whose answer is in flight, if any. */
+    this.responding = null;
+    this.revision = -1;
+    this.closed = false;
+  }
+
+  /** Reads the pending list; the watch reports only what changes after it opened. */
+  async refresh() {
+    try {
+      this.accept(await query(this.endpoint, 'pendingInteractions', { sessionId: this.sessionId }));
+    } catch (error) {
+      if (!this.closed) this.hooks.report(t('pendingRequestsFailed', { value: error.message }));
+    }
+  }
+
+  /** Applies a pending list from the watch or `pendingInteractions`; an older revision is ignored. */
+  accept(pending) {
+    if (this.closed || pending.revision < this.revision) return;
+    this.revision = pending.revision;
+    this.entries = pending.entries;
+    const open = new Set(pending.entries.map((entry) => entry.requestId));
+    for (const id of [...this.records.keys()]) if (!open.has(id)) this.records.delete(id);
+    for (const id of [...this.drafts.keys()]) if (!open.has(id)) this.drafts.delete(id);
+    for (const id of [...this.failures.keys()]) if (!open.has(id)) this.failures.delete(id);
+    for (const [id, form] of [...this.forms]) {
+      if (open.has(id)) continue;
+      form.release();
+      this.forms.delete(id);
+    }
+    for (const entry of pending.entries) {
+      if (!this.records.has(entry.requestId)) this.read(entry.requestId);
+    }
+    this.hooks.changed();
+  }
+
+  async read(requestId) {
+    this.records.set(requestId, null);
+    this.failures.delete(requestId);
+    try {
+      const record = await query(this.endpoint, 'interaction', {
+        sessionId: this.sessionId,
+        requestId,
+      });
+      if (this.closed || !this.entries.some((entry) => entry.requestId === requestId)) return;
+      this.adopt(record);
+    } catch (error) {
+      this.records.delete(requestId);
+      if (this.closed) return;
+      // The pending list may not change while the agent waits, so nothing
+      // else would read it again.
+      this.failures.set(requestId, error.message);
+      this.hooks.report(t('requestFailed', { id: requestId, value: error.message }));
+      this.hooks.changed();
+    }
+  }
+
+  /** Keeps a request's record, with a draft to fill in when it is an open form. */
+  adopt(record) {
+    const id = record.requestId;
+    this.records.set(id, record);
+    if (record.request.kind === 'form' && !record.outcome && !this.forms.has(id)) {
+      this.forms.set(id, new FormDraft(record.request.fields, () => this.hooks.changed()));
+    }
+    this.hooks.changed();
+  }
+
+  /** Reads again the requests whose read failed. */
+  retry() {
+    for (const id of [...this.failures.keys()]) this.read(id);
+    this.hooks.changed();
+  }
+
+  /** The first request that has been read and not answered. */
+  current() {
+    for (const entry of this.entries) {
+      const record = this.records.get(entry.requestId);
+      if (record && !record.outcome) return record;
+    }
+    return null;
+  }
+
+  /**
+   * Answers one request. Never retried: a lost reply must not become a
+   * second answer, and the pending list says what happened.
+   */
+  async respond(record, answer) {
+    if (this.responding !== null || this.closed) return;
+    this.responding = record.requestId;
+    this.hooks.changed();
+    try {
+      const result = await operation(this.endpoint, 'respond', {
+        sessionId: this.sessionId,
+        requestId: record.requestId,
+        answer,
+      });
+      log(`interactions: ${record.request.kind} ${record.requestId} ${result.kind}`);
+      if (result.kind === 'rejected') {
+        throw new Error(t('hostRejectedAnswer', { value: result.reason }));
+      }
+      // The record holds the canonical outcome, which may be another client's answer.
+      this.records.set(record.requestId, result.record);
+    } catch (error) {
+      if (!this.closed) {
+        this.hooks.report(t('answerFailed', { value: error.message }));
+        this.refresh();
+      }
+    } finally {
+      this.responding = null;
+      this.hooks.changed();
+    }
+  }
+
+  /** Records one part of a question; a single question is answered at once. */
+  choose(record, index, answer) {
+    const count = record.request.questions.length;
+    const draft = this.drafts.get(record.requestId) ?? new Array(count).fill(undefined);
+    draft[index] = answer;
+    this.drafts.set(record.requestId, draft);
+    if (count === 1) this.respond(record, { kind: 'question', answers: draft });
+    else this.hooks.changed();
+  }
+
+  /** Sends a filled-in form, or marks the first field that cannot be sent. */
+  submitForm(record) {
+    const form = this.forms.get(record.requestId);
+    const values = form?.values();
+    if (values) this.respond(record, { kind: 'form', action: 'accept', values });
+    else this.hooks.changed();
+  }
+
+  close() {
+    this.closed = true;
+    for (const form of this.forms.values()) form.release();
+    this.forms.clear();
+  }
+}
+
+/** The fields typed as text: a string, or a number read from text. */
+const TEXTUAL = new Set(['string', 'number', 'integer']);
+
+/** What a text field shows while it is empty, by format: an example, the same in every language. */
+const PLACEHOLDERS = {
+  email: 'name@example.com',
+  uri: 'https://',
+  date: 'YYYY-MM-DD',
+  'date-time': 'YYYY-MM-DDThh:mm:ssZ',
+};
+/** What a number field shows while it is empty, by kind: the message that says what to enter. */
+const PLACEHOLDER_MESSAGES = {
+  number: 'placeholderNumber',
+  integer: 'placeholderWholeNumber',
+};
+
+/** The placeholder of a field typed as text, in the current language. */
+function placeholder(field) {
+  const name = field.format ?? field.kind;
+  if (Object.hasOwn(PLACEHOLDERS, name)) return PLACEHOLDERS[name];
+  return Object.hasOwn(PLACEHOLDER_MESSAGES, name) ? t(PLACEHOLDER_MESSAGES[name]) : '';
+}
+
+/**
+ * The answers to one form while they are filled in: a text state for each
+ * field typed as text, and the chosen value (or values) for the rest. A field
+ * starts from the request's default. An optional field is sent only when it
+ * has a value; a required one must have a valid one.
+ */
+class FormDraft {
+  constructor(fields, changed) {
+    this.fields = fields;
+    this.changed = changed;
+    /** The field that stopped the last submission and why; index -1 is the whole form. */
+    this.problem = null;
+    /** Stop the text states' placeholders following the language; `release` calls them. */
+    this.unsubscribe = [];
+    this.drafts = fields.map((field, index) => {
+      if (!TEXTUAL.has(field.kind)) {
+        return field.default ?? (field.kind === 'multi_select' ? [] : null);
+      }
+      const state = InputState.new({
+        placeholder: placeholder(field),
+        value: field.default == null ? '' : String(field.default),
+      });
+      state.on('change', () => this.edited(index));
+      this.unsubscribe.push(onLanguageChange(() => state.set_placeholder(placeholder(field))));
+      return state;
+    });
+  }
+
+  /** Text state for a field typed as text. */
+  state(index) {
+    return this.drafts[index];
+  }
+
+  /** The value chosen for a boolean or select field. */
+  chosen(index) {
+    return this.drafts[index];
+  }
+
+  /**
+   * Chooses a value for a boolean or single select. Choosing the chosen value
+   * of an optional field clears it, so it can be left out again.
+   */
+  choose(index, value) {
+    const field = this.fields[index];
+    this.drafts[index] = !field.required && this.drafts[index] === value ? null : value;
+    this.edited(index);
+  }
+
+  /** Adds or removes one value of a multi select, keeping the options' order. */
+  toggle(index, value) {
+    const field = this.fields[index];
+    const current = new Set(this.drafts[index]);
+    if (current.has(value)) current.delete(value);
+    else current.add(value);
+    this.drafts[index] = field.options.map((option) => option.value).filter((v) => current.has(v));
+    this.edited(index);
+  }
+
+  edited(index) {
+    if (this.problem !== null && (this.problem.index === index || this.problem.index === -1)) {
+      this.problem = null;
+    }
+    this.changed();
+  }
+
+  /**
+   * The values to send, or null after recording the first field that cannot
+   * be sent. The checks are the Host's (facts/src/capability/form_validation.rs)
+   * and the form editor's size limits.
+   */
+  values() {
+    // Without a prototype, and defined rather than assigned, so that a field
+    // named `__proto__` is an answer like any other (as the TUI's form editor
+    // builds it).
+    const values = Object.create(null);
+    for (const [index, field] of this.fields.entries()) {
+      const draft = this.drafts[index];
+      const raw = TEXTUAL.has(field.kind) ? draft.value() : draft;
+      const empty =
+        raw === null || raw === '' || (field.kind === 'multi_select' && raw.length === 0);
+      if (empty && !field.required) continue;
+      const parsed = TEXTUAL.has(field.kind) && field.kind !== 'string' ? number(raw) : raw;
+      // A required field is missing only with no choice made: an empty text
+      // or no options picked can be an answer, which the field's own limits
+      // judge, as the Host does.
+      const problem = raw === null ? t('required') : fieldProblem(field, parsed);
+      if (problem !== null) {
+        this.problem = { index, message: problem };
+        return null;
+      }
+      Object.defineProperty(values, field.name, {
+        value: parsed,
+        enumerable: true,
+        configurable: true,
+        writable: true,
+      });
+    }
+    const answer = {
+      kind: 'form_answer',
+      action: 'accept',
+      values,
+      committedAt: Number.MAX_SAFE_INTEGER,
+    };
+    if (utf8Length(JSON.stringify(answer)) > 8192) {
+      this.problem = { index: -1, message: t('answersTooLong') };
+      return null;
+    }
+    this.problem = null;
+    return values;
+  }
+
+  /**
+   * Releases the text states, once the request is answered or gone or its
+   * session closed; their placeholders stop following the language first.
+   */
+  release() {
+    for (const stop of this.unsubscribe.splice(0)) stop();
+    for (const [index, field] of this.fields.entries()) {
+      if (TEXTUAL.has(field.kind)) this.drafts[index].release();
+    }
+  }
+}
+
+/** A number typed as text, or null when the text is not one (the form editor's pattern). */
+function number(text) {
+  const trimmed = text.trim();
+  return /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(trimmed) ? Number(trimmed) : null;
+}
+
+function utf8Length(text) {
+  let bytes = 0;
+  for (const char of text) {
+    const code = char.codePointAt(0);
+    bytes += code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4;
+  }
+  return bytes;
+}
+
+/** Why a field's value cannot be sent, or null when it can. */
+function fieldProblem(field, value) {
+  switch (field.kind) {
+    case 'string': {
+      const length = [...value].length;
+      if (length < (field.minLength ?? 0)) {
+        return tn(field.minLength, 'useAtLeastCharactersOne', 'useAtLeastCharactersOther');
+      }
+      if (length > (field.maxLength ?? 2048) || utf8Length(value) > 2048) {
+        return tn(field.maxLength ?? 2048, 'useAtMostCharactersOne', 'useAtMostCharactersOther');
+      }
+      return formatProblem(value, field.format);
+    }
+    case 'number':
+    case 'integer':
+      if (value === null || !Number.isFinite(value)) {
+        return field.kind === 'integer' ? t('enterWholeNumber') : t('enterNumber');
+      }
+      if (field.kind === 'integer' && !Number.isSafeInteger(value)) return t('enterWholeNumber');
+      if (value < (field.minimum ?? -Infinity) || value > (field.maximum ?? Infinity)) {
+        return range(field, true);
+      }
+      return null;
+    case 'multi_select': {
+      const fewest = field.minItems ?? 0;
+      const most = field.maxItems ?? field.options.length;
+      if (value.length < fewest || value.length > most) return chooseCount(fewest, most);
+      return null;
+    }
+    default:
+      return null;
+  }
+}
+
+function formatProblem(value, format) {
+  switch (format) {
+    case 'email':
+      return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? null : t('enterEmail');
+    case 'uri':
+      return uri(value) ? null : t('enterFullAddress');
+    case 'date':
+      return calendar(value, false) ? null : t('enterDate');
+    case 'date-time':
+      return calendar(value, true) ? null : t('enterDateTime');
+    default:
+      return null;
+  }
+}
+
+/**
+ * Whether `value` reads as an absolute URL, near enough to the Host's check
+ * (`url::Url::parse`): a scheme, then for the web schemes a host, with an
+ * IPv6 address bracketed and a port up to 65535. Spaces around it are
+ * ignored, as the parser ignores them. Anything this lets through that the
+ * parser refuses comes back from the Host as a refused answer.
+ */
+function uri(value) {
+  const trimmed = value.replace(/^[\u0000-\u0020]+|[\u0000-\u0020]+$/g, '');
+  const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):(.*)$/s.exec(trimmed);
+  if (scheme === null) return false;
+  if (!/^(?:https?|wss?|ftp)$/i.test(scheme[1])) return true;
+  const web =
+    /^\/\/(?:[^\/?#@]*@)?(\[[0-9a-fA-F:.]+\]|[^\s\/?#:@\[\]]+)(?::(\d*))?(?:[\/?#].*)?$/s.exec(
+      scheme[2],
+    );
+  return web !== null && (web[2] === undefined || web[2] === '' || Number(web[2]) <= 65535);
+}
+
+/** Whether `value` is a real calendar date (and time), as the Host checks it. */
+function calendar(value, time) {
+  const match = time
+    ? /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/.exec(
+        value,
+      )
+    : /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
+  if (match === null) return false;
+  const n = (index) => Number(match[index] ?? 0);
+  const [year, month, day] = [n(1), n(2), n(3)];
+  const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
+  const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
+  return (
+    month >= 1 &&
+    month <= 12 &&
+    day >= 1 &&
+    day <= days[month - 1] &&
+    (!time || (n(4) <= 23 && n(5) <= 59 && n(6) <= 59 && n(7) <= 23 && n(8) <= 59))
+  );
+}
+
+/**
+ * The messages for a number field's limits, by kind and by which ends are
+ * set: the hint ("A number from 1 to 10") and the problem ("Enter a whole
+ * number of at least 1").
+ */
+const RANGES = {
+  number: {
+    both: ['numberFromTo', 'enterNumberFromTo'],
+    low: ['numberAtLeast', 'enterNumberAtLeast'],
+    high: ['numberAtMost', 'enterNumberAtMost'],
+  },
+  integer: {
+    both: ['wholeNumberFromTo', 'enterWholeNumberFromTo'],
+    low: ['wholeNumberAtLeast', 'enterWholeNumberAtLeast'],
+    high: ['wholeNumberAtMost', 'enterWholeNumberAtMost'],
+  },
+};
+
+/** A number field's limits as its hint, or with `enter` as what to enter. */
+function range(field, enter) {
+  const low = field.minimum;
+  const high = field.maximum;
+  const ends = low != null && high != null ? 'both' : low != null ? 'low' : 'high';
+  const [hint, problem] = RANGES[field.kind === 'integer' ? 'integer' : 'number'][ends];
+  return t(enter ? problem : hint, { low, high });
+}
+
+/** "Choose 1 to 3 options", "Choose at most 2 options", "Choose 1 option", … */
+function chooseCount(fewest, most) {
+  if (fewest === most) return tn(most, 'chooseOptionsOne', 'chooseOptionsOther');
+  if (fewest === 0) return tn(most, 'chooseAtMostOptionsOne', 'chooseAtMostOptionsOther');
+  return tn(most, 'chooseOptionsRangeOne', 'chooseOptionsRangeOther', { low: fewest });
+}
+
+/**
+ * The card for the first pending request, or null when there is none. It sits
+ * above the composer in the reading column.
+ * @param {Interactions} interactions
+ */
+export function interactionCard(interactions, cx) {
+  const record = interactions.current();
+  if (record === null) return interactions.failures.size > 0 ? failureCard(interactions, cx) : null;
+  const m = palette(cx);
+  const busy = interactions.responding !== null;
+  const waiting = interactions.entries.length - 1;
+  const request = record.request;
+  const id = record.requestId;
+  const respond = (answer) => interactions.respond(record, answer);
+
+  let title;
+  /** @type {any[]} */
+  let body = [];
+  /** @type {any[]} */
+  let actions = [];
+  switch (request.kind) {
+    case 'service_approval':
+      title = t('allowServiceCall');
+      body = [
+        detail(
+          t('serviceCallDetail', {
+            tool: request.toolName,
+            call: `${request.service}.${request.method}`,
+          }),
+          cx,
+        ),
+      ];
+      actions = decisions(id, busy, cx, (decision) =>
+        respond({ kind: 'service_approval', decision }),
+      );
+      break;
+    case 'client_capability': {
+      const target = request.target;
+      title = t('allowCapability');
+      body = [
+        detail(
+          t('capabilityDetail', { tool: target.toolName, capability: capability(target) }),
+          cx,
+        ),
+      ];
+      actions = decisions(id, busy, cx, (decision) =>
+        respond({ kind: 'client_capability', decision }),
+      );
+      break;
+    }
+    case 'permissions': {
+      const permissions = request.request.permissions;
+      title = t('allowMoreAccess');
+      body = [detail(request.request.reason, cx)];
+      if (request.request.command) {
+        body.push(command(request.request.command, cx));
+      }
+      actions = [
+        ...SCOPES.map(([scope, label]) =>
+          controlButton(
+            `${id}-${scope}`,
+            t(label),
+            scope === 'once' ? 'primary' : 'outline',
+            busy,
+            cx,
+          ).on_click(() =>
+            respond({ kind: 'permissions', decision: { decision: 'allow', scope, permissions } }),
+          ),
+        ),
+        controlButton(`${id}-deny`, t('deny'), 'outline', busy, cx).on_click(() =>
+          respond({ kind: 'permissions', decision: { decision: 'deny' } }),
+        ),
+      ];
+      break;
+    }
+    case 'question': {
+      const draft = interactions.drafts.get(id) ?? [];
+      title = request.questions.length === 1 ? t('agentAsks') : t('agentAsksSeveral');
+      body = request.questions.map((question, index) =>
+        questionPart(id, question, index, draft[index], busy, cx, (answer) =>
+          interactions.choose(record, index, answer),
+        ),
+      );
+      if (request.questions.length > 1) {
+        const complete = request.questions.every((_, index) => draft[index] !== undefined);
+        actions = [
+          controlButton(`${id}-submit`, t('submit'), 'primary', busy || !complete, cx).on_click(
+            () => respond({ kind: 'question', answers: draft }),
+          ),
+        ];
+      }
+      break;
+    }
+    case 'form': {
+      const form = interactions.forms.get(id);
+      title = t('agentNeedsDetails');
+      body = [
+        requester(request.requester, cx),
+        detail(request.message, cx),
+        form === undefined ? null : formFields(id, form, busy, cx),
+      ].filter((part) => part !== null);
+      // A field's problem shows on its row, which may be scrolled away, so it
+      // is named here too.
+      const problem = form?.problem ?? null;
+      if (problem !== null) {
+        const label = problem.index >= 0 ? request.fields[problem.index].label : null;
+        const message =
+          label !== null ? t('fieldProblem', { label, problem: problem.message }) : problem.message;
+        body.push(text(message, 'small', m.destructive));
+      }
+      actions = [
+        controlButton(
+          `${id}-submit`,
+          t('submit'),
+          'primary',
+          busy || form === undefined,
+          cx,
+        ).on_click(() => interactions.submitForm(record)),
+        controlButton(`${id}-decline`, t('decline'), 'outline', busy, cx).on_click(() =>
+          respond({ kind: 'form', action: 'decline' }),
+        ),
+        controlButton(`${id}-cancel`, t('cancelRequest'), 'outline', busy, cx).on_click(() =>
+          respond({ kind: 'form', action: 'cancel' }),
+        ),
+      ];
+      break;
+    }
+    default:
+      title = t('agentWaitingUnknown');
+      body = [detail(t('answerInTerminal', { kind: request.kind }), cx)];
+  }
+
+  return column(
+    frame(cx)
+      .child(
+        h_flex()
+          .gap(8)
+          .items_center()
+          .child(icon('status-waiting', 16, m.warning))
+          .child(text(title, 'strong', m.ink).flex_1().min_w_0())
+          .when(waiting > 0, (el) =>
+            el.child(text(t('moreWaiting', { count: waiting }), 'small', m.inkMuted).flex_none()),
+          ),
+      )
+      .children(body)
+      .when(actions.length > 0, (el) =>
+        el.child(
+          h_flex()
+            .flex_wrap()
+            .gap(8)
+            .items_center()
+            .children(actions)
+            .child(div().flex_1())
+            .child(text(busy ? t('answering') : t('waitingForAnswer'), 'small', m.inkMuted)),
+        ),
+      ),
+  );
+}
+
+function failureCard(interactions, cx) {
+  const m = palette(cx);
+  const [reason] = interactions.failures.values();
+  return column(
+    frame(cx)
+      .child(
+        h_flex()
+          .gap(8)
+          .items_center()
+          .child(icon('status-failed', 16, m.destructive))
+          .child(text(t('requestUnreadable'), 'strong', m.ink).flex_1().min_w_0()),
+      )
+      .child(detail(reason, cx))
+      .child(
+        h_flex().child(
+          controlButton('interaction-retry', t('tryAgain'), 'primary', false, cx).on_click(() =>
+            interactions.retry(),
+          ),
+        ),
+      ),
+  );
+}
+
+function decisions(id, busy, cx, decide) {
+  return [
+    controlButton(`${id}-allow`, t('allow'), 'primary', busy, cx).on_click(() => decide('allow')),
+    controlButton(`${id}-deny`, t('deny'), 'outline', busy, cx).on_click(() => decide('deny')),
+  ];
+}
+
+/** The reading column the card sits in, above the composer. */
+function column(card) {
+  return h_flex().flex_none().w_full().px(24).pt(8).justify_center().child(card);
+}
+
+/** The card's box. */
+function frame(cx) {
+  const m = palette(cx);
+  return v_flex()
+    .w_full()
+    .max_w(720)
+    .p(12)
+    .gap(8)
+    .rounded(12)
+    .border_1()
+    .border_color(m.border)
+    .bg(m.plate);
+}
+
+function capability(target) {
+  const scope = target.scope;
+  if (scope.kind === 'browser_origin') {
+    return t('capabilityOnOrigin', { capability: target.capability, origin: scope.origin });
+  }
+  if (scope.kind === 'mcp_tool') return `${scope.serverId}.${scope.toolName}`;
+  return target.capability;
+}
+
+function detail(content, cx) {
+  return text(content, 'body', palette(cx).ink);
+}
+
+function command(run, cx) {
+  const m = palette(cx);
+  return v_flex()
+    .px(12)
+    .py(10)
+    .rounded(10)
+    .bg(m.code)
+    .text_size(14)
+    .line_height('20px')
+    .font_family('Menlo')
+    .text_color(m.ink)
+    .child(div().child(`$ ${run.command}`))
+    .child(
+      div()
+        .text_size(12)
+        .text_color(m.inkMuted)
+        .child(t('commandIn', { path: run.cwd })),
+    );
+}
+
+/** A 28px choice button, drawn as picked with the accent ring. */
+function optionButton(key, label, picked, busy, cx) {
+  const m = palette(cx);
+  return controlButton(key, label, 'outline', busy, cx)
+    .h(28)
+    .px(10)
+    .when(picked, (el) => el.bg(m.selected).border_color(m.accent));
+}
+
+/** Who asks for the form: the requester the Host names, and where it comes from. */
+function requester(who, cx) {
+  const m = palette(cx);
+  return h_flex()
+    .gap(6)
+    .items_center()
+    .child(text(t('fromRequester', { name: who.name }), 'small', m.inkMuted).flex_none())
+    .when(who.source != null, (el) =>
+      el.child(text(who.source, 'small', m.inkDisabled).min_w_0().truncate()),
+    );
+}
+
+/**
+ * A form's fields, one row each as settings draws them: the label and what to
+ * know about it on the left, the control on the right. The rows scroll when a
+ * long form would push the composer off the plate.
+ * @param {FormDraft} form
+ */
+function formFields(id, form, busy, cx) {
+  return v_flex()
+    .id(`${id}-fields`)
+    .max_h(320)
+    .overflow_y_scrollbar()
+    .children(form.fields.map((field, index) => formField(id, form, field, index, busy, cx)));
+}
+
+function formField(id, form, field, index, busy, cx) {
+  const m = palette(cx);
+  const key = `${id}-field-${index}`;
+  let control;
+  switch (field.kind) {
+    case 'string':
+    case 'number':
+    case 'integer':
+      control = div()
+        .w(280)
+        .child(textField(form.state(index), cx));
+      break;
+    case 'boolean':
+      control = choices([
+        optionButton(`${key}-yes`, t('yes'), form.chosen(index) === true, busy, cx).on_click(() =>
+          form.choose(index, true),
+        ),
+        optionButton(`${key}-no`, t('no'), form.chosen(index) === false, busy, cx).on_click(() =>
+          form.choose(index, false),
+        ),
+      ]);
+      break;
+    case 'single_select':
+      control = choices(
+        field.options.map((entry, choice) =>
+          optionButton(
+            `${key}-${choice}`,
+            entry.label,
+            form.chosen(index) === entry.value,
+            busy,
+            cx,
+          ).on_click(() => form.choose(index, entry.value)),
+        ),
+      );
+      break;
+    case 'multi_select':
+      control = choices(
+        field.options.map((entry, choice) =>
+          optionButton(
+            `${key}-${choice}`,
+            entry.label,
+            form.chosen(index).includes(entry.value),
+            busy,
+            cx,
+          ).on_click(() => form.toggle(index, entry.value)),
+        ),
+      );
+      break;
+    default:
+      control = text(t('fieldKindUnsupported', { kind: field.kind }), 'small', m.inkMuted);
+  }
+  const problem = form.problem?.index === index ? form.problem.message : null;
+  const note =
+    problem ?? ([field.description, fieldHint(field)].filter(Boolean).join(' · ') || null);
+  return h_flex()
+    .w_full()
+    .py(8)
+    .gap(16)
+    .items_center()
+    .when(index > 0, (el) => el.border_t_1().border_color(m.borderSoft))
+    .child(
+      v_flex()
+        .flex_1()
+        .min_w_0()
+        .child(
+          h_flex()
+            .gap(6)
+            .items_center()
+            .child(text(field.label, 'label', m.ink).min_w_0().truncate())
+            .when(field.required, (el) =>
+              el.child(text(t('required'), 'small', m.inkMuted).flex_none()),
+            ),
+        )
+        .when(note !== null, (el) =>
+          el.child(text(note, 'small', problem !== null ? m.destructive : m.inkMuted)),
+        ),
+    )
+    .child(h_flex().flex_none().max_w(360).justify_end().child(control));
+}
+
+function choices(buttons) {
+  return h_flex().flex_wrap().justify_end().gap(6).children(buttons);
+}
+
+/** The limits a field states, in words, or null when it has none. */
+function fieldHint(field) {
+  switch (field.kind) {
+    case 'string': {
+      const low = field.minLength ?? null;
+      const high = field.maxLength ?? null;
+      if (low !== null && high !== null) {
+        return tn(high, 'charactersRangeOne', 'charactersRangeOther', { low });
+      }
+      if (low !== null) return tn(low, 'atLeastCharactersOne', 'atLeastCharactersOther');
+      if (high !== null) return tn(high, 'atMostCharactersOne', 'atMostCharactersOther');
+      return null;
+    }
+    case 'number':
+    case 'integer':
+      if (field.minimum == null && field.maximum == null) return null;
+      return range(field, false);
+    case 'multi_select': {
+      const fewest = field.minItems ?? 0;
+      const most = field.maxItems ?? field.options.length;
+      if (fewest === 0 && most === field.options.length) return t('chooseAny');
+      return chooseCount(fewest, most);
+    }
+    default:
+      return null;
+  }
+}
+
+function questionPart(id, question, index, chosen, busy, cx, choose) {
+  const option = (key, label, picked) => optionButton(key, label, picked, busy, cx);
+  return v_flex()
+    .gap(6)
+    .child(detail(question.question, cx))
+    .child(
+      h_flex()
+        .flex_wrap()
+        .gap(6)
+        .children(
+          question.options.map((entry, choice) =>
+            option(`${id}-${index}-${choice}`, entry.label, chosen === entry.label)
+              .when(entry.description !== undefined, (el) => el.tooltip(entry.description))
+              .on_click(() => choose(entry.label)),
+          ),
+        )
+        .child(
+          option(`${id}-${index}-skip`, t('skip'), chosen === null).on_click(() => choose(null)),
+        ),
+    );
+}
diff --git a/products/maka/desktop-shell/app/jsconfig.json b/products/maka/desktop-shell/app/jsconfig.json
new file mode 100644
index 0000000000..f1319591af
--- /dev/null
+++ b/products/maka/desktop-shell/app/jsconfig.json
@@ -0,0 +1,32 @@
+{
+  "// why": [
+    "Written once by gpui-shell, then yours: an existing jsconfig.json or",
+    "tsconfig.json is never replaced, and this file is not rewritten.",
+    "",
+    "`moduleResolution` is how a bare specifier is answered. Left to be",
+    "inferred it can still land on the resolution that never looks in",
+    "node_modules, and a Git dependency the runtime resolves fine is",
+    "underlined as a module the editor cannot find.",
+    "",
+    "`lib` decides which globals exist. The default hands a script the",
+    "browser's — a `console`, a `localStorage`, a `Window` this runtime does",
+    "not have — and their declarations collide with the ones gpui-kit.d.ts makes,",
+    "so the file describing the API is itself reported as the error.",
+    "",
+    "`strictNullChecks` is off, and this one is the runtime's shape rather than",
+    "a preference. A view assigns its state in `init`, which TypeScript cannot",
+    "see as definite assignment the way it sees a constructor, so every field",
+    "would read as possibly-undefined and every use would want a `?.` that",
+    "means nothing at run time. Turning it on would buy noise, not safety."
+  ],
+  "compilerOptions": {
+    "target": "ES2022",
+    "module": "ES2022",
+    "moduleResolution": "bundler",
+    "lib": ["ES2022"],
+    "checkJs": true,
+    "strict": true,
+    "strictNullChecks": false,
+    "noEmit": true
+  }
+}
diff --git a/products/maka/desktop-shell/app/locale.js b/products/maka/desktop-shell/app/locale.js
new file mode 100644
index 0000000000..cdc8efda8b
--- /dev/null
+++ b/products/maka/desktop-shell/app/locale.js
@@ -0,0 +1,114 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// The language the desktop speaks: English, Simplified Chinese or
+// Traditional Chinese, the three the TUI and the native client have. Unless
+// the user picks one in Settings it follows the one the system asks for
+// (`maka-locale`), mapped as the TUI maps a tag
+// (plugins/shared/locale.ts `languageIndex`). The choice is kept in this
+// application's localStorage. Words come from messages.js through `t`.
+
+import { system } from 'maka-locale';
+import { MESSAGES } from './messages.js';
+
+/** The languages, in the order of a message's entries. */
+export const LANGUAGES = ['en', 'zh-CN', 'zh-TW'];
+/** What each language is called, in itself, as a language menu lists it. */
+export const LANGUAGE_NAMES = { en: 'English', 'zh-CN': '简体中文', 'zh-TW': '繁體中文' };
+/** The choices Settings offers: follow the system, or one of LANGUAGES. */
+export const LANGUAGE_CHOICES = ['system', ...LANGUAGES];
+
+const STORAGE_KEY = 'maka.language';
+
+/** Which of LANGUAGES a BCP 47 tag means. */
+export function languageOf(tag) {
+  const lower = String(tag).toLowerCase();
+  if (!lower.startsWith('zh')) return 'en';
+  return lower.includes('hant') || /^zh-(tw|hk|mo)/.test(lower) ? 'zh-TW' : 'zh-CN';
+}
+
+function stored() {
+  try {
+    const value = localStorage.getItem(STORAGE_KEY);
+    return LANGUAGE_CHOICES.includes(value) ? value : 'system';
+  } catch {
+    return 'system';
+  }
+}
+
+let preference = stored();
+let current = resolve(preference);
+const listeners = new Set();
+
+function resolve(choice) {
+  return choice === 'system' ? languageOf(system()) : choice;
+}
+
+/** The language in use. */
+export function language() {
+  return current;
+}
+
+/** The choice in Settings: 'system' or one of LANGUAGES. */
+export function languagePreference() {
+  return preference;
+}
+
+/**
+ * Keeps the choice and switches to the language it means. Listeners run at
+ * once, inside the caller's event, so they may update retained state such
+ * as a field's placeholder; the caller redraws.
+ */
+export function setLanguagePreference(next) {
+  if (!LANGUAGE_CHOICES.includes(next) || next === preference) return;
+  preference = next;
+  localStorage.setItem(STORAGE_KEY, next);
+  const language = resolve(next);
+  if (language === current) return;
+  current = language;
+  for (const listener of listeners) listener(language);
+}
+
+/** Calls `listener(language)` after each switch; answers a function that stops it. */
+export function onLanguageChange(listener) {
+  listeners.add(listener);
+  return () => listeners.delete(listener);
+}
+
+/**
+ * The message `key` in the current language, with each `{name}` replaced
+ * by `params[name]`. An unknown key is a bug: it throws.
+ */
+export function t(key, params) {
+  const message = MESSAGES[key];
+  if (message === undefined) throw new Error(`no message ${key}`);
+  const text = message[LANGUAGES.indexOf(current)];
+  return params === undefined ? text : fill(text, params);
+}
+
+/** `one` for a count of 1 and `other` for the rest, with `{count}` filled in. */
+export function tn(count, one, other, params = {}) {
+  return t(count === 1 ? one : other, { ...params, count });
+}
+
+function fill(text, params) {
+  return text.replace(/\{(\w+)\}/g, (whole, name) =>
+    Object.hasOwn(params, name) ? String(params[name]) : whole,
+  );
+}
diff --git a/products/maka/desktop-shell/app/main.js b/products/maka/desktop-shell/app/main.js
new file mode 100644
index 0000000000..284b0096f1
--- /dev/null
+++ b/products/maka/desktop-shell/app/main.js
@@ -0,0 +1,903 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// The Maka desktop window: tasks on the canvas at the side, and the plate
+// beside them with the selected task's transcript and the composer, or the
+// empty state for a new task, or WorkHub (workhub.js), whose conversation is
+// drawn as a task's, or the Search page (search.js). Settings (settings.js)
+// takes the sidebar and the plate while open. The look follows the native
+// client (theme.js). Every Host call goes through `maka-host` (see host.js);
+// MAKA_HOST_ENDPOINT and MAKA_HOST_EPOCH name the Host.
+//
+// An optional `dev.js` beside this file (never committed) can turn on the
+// debug log and drive the window without focus:
+//
+//   export default { debug: true, run: async ({ app, cx, log }) => { ... } };
+
+import { View, div } from 'gpui-kit';
+import { v_flex, h_flex } from 'gpui-base';
+import { query, operation, log, setDebug } from './host.js';
+import { SessionCatalog, SidebarState, sidebar } from './sessions.js';
+import { Transcript, transcriptPane } from './transcript.js';
+import { Composer, composerPane } from './composer.js';
+import { interactionCard } from './interactions.js';
+import { Settings, settingsNav, settingsPage } from './settings.js';
+import { Directories, folderPicker } from './directories.js';
+import { WorkHub, workhubTabs, workhubBody, chatHint } from './workhub.js';
+import { Search, searchPage } from './search.js';
+import { usable } from './subagents.js';
+import { followAppearance, palette, text, icon, controlButton, ghostButton } from './theme.js';
+import { t, onLanguageChange } from './locale.js';
+
+/**
+ * Where the window controls start, from the left of the strip or header that
+ * holds them: the traffic lights sit at 16 from the window's left (window.rs)
+ * and span 56, and the controls start 8 after them. The header keeps the same
+ * clearance from the plate's edge, as the native client's title bar does.
+ */
+const LIGHTS_END = 80;
+
+/** WorkHub and the Search page as places in the Back and Forward lists, beside session ids. */
+const WORKHUB = 'workhub';
+const SEARCH = 'search';
+
+/** The application's shortcuts, as the native client binds them. */
+const SHORTCUTS = [
+  { keystroke: 'secondary-n', action: 'maka.new-task' },
+  { keystroke: 'secondary-b', action: 'maka.toggle-sidebar' },
+  { keystroke: 'secondary-[', action: 'maka.back' },
+  { keystroke: 'secondary-]', action: 'maka.forward' },
+  { keystroke: 'secondary-,', action: 'maka.settings' },
+  { keystroke: 'secondary-shift-f', action: 'maka.search' },
+  { keystroke: 'secondary-.', action: 'maka.stop' },
+  { keystroke: 'f2', action: 'maka.rename-task' },
+];
+
+export default class MakaDesktop extends View {
+  init(_props, cx) {
+    // The context `init` receives may be held across awaits; the Host loops
+    // below notify through it.
+    this.cx = cx;
+    const hooks = { changed: () => this.cx.notify(), report: (message) => this.report(message) };
+    this.hooks = hooks;
+    this.catalog = new SessionCatalog({
+      ...hooks,
+      changed: () => {
+        // The header shows the catalog's title, which a first message can change.
+        // A page read before a change the app made itself (a permission
+        // level) can arrive after it; an older revision is not taken.
+        const entry = this.catalog.sessions.find((session) => session.id === this.transcript?.id);
+        if (
+          entry &&
+          this.transcript !== null &&
+          entry.revision >= this.transcript.session.revision
+        ) {
+          this.transcript.session = entry;
+        }
+        hooks.changed();
+      },
+    });
+    // With no session selected, sending starts one in the folder the
+    // composer's folder chip shows (the first root until one is chosen).
+    this.composer = new Composer(
+      hooks,
+      () => this.current(),
+      () => {
+        const folder = this.startFolder();
+        return folder === null ? Promise.resolve(null) : this.newSession(folder);
+      },
+    );
+    // A model added or chosen in Settings is what the composer sends with next,
+    // and the subagents Settings reads or saves are what WorkHub's hint goes by.
+    this.settings = new Settings({
+      ...hooks,
+      modelsChanged: () => this.composer.loadModel(),
+      presetsChanged: (snapshot) => this.workhub.acceptPresets(snapshot),
+    });
+    /** @type {Transcript | null} */
+    this.transcript = null;
+    /** Where a session starts when none is selected, and the menu to choose it. */
+    this.directories = new Directories(hooks);
+    this.workhub = new WorkHub(hooks, this.directories);
+    // A result opens its task as the sidebar does; the session the Host
+    // answered with gives way to the catalog's entry, which is fresher.
+    // Archived tasks, which the catalog does not list, open too.
+    const listed = (session) =>
+      this.catalog.sessions.find((entry) => entry.id === session.id) ?? session;
+    this.search = new Search(
+      {
+        changed: hooks.changed,
+        openTask: (session) => this.select(listed(session)),
+        openPassage: (session, anchor) => this.select(listed(session), true, anchor),
+        back: () => this.go(-1),
+      },
+      cx,
+    );
+    // A rename shows in the open task's header at once; an archived task
+    // that is open is left for the start pane, as after closing one.
+    this.sidebarUi = new SidebarState(
+      {
+        ...hooks,
+        renamed: (session) => {
+          if (this.transcript?.id === session.id) this.transcript.session = session;
+        },
+        archived: (sessionId) => {
+          if (this.transcript?.id === sessionId) this.home();
+        },
+        focusRoot: () => this.rootFocus.focus(),
+      },
+      cx,
+    );
+    this.creating = false;
+    this.error = null;
+    this.sidebarHidden = false;
+    /**
+     * The places Back and Forward go to: a session id, WORKHUB, SEARCH, or
+     * null for the start pane. The current place is not on either list.
+     */
+    this.history = { back: [], forward: [] };
+    // The shortcuts' actions are handled on the root below, and an action
+    // only reaches the elements on the focus path. So the root holds the
+    // focus whenever nothing inside it does: from the start, and after a
+    // press on anything that does not take focus itself (GPUI moves focus to
+    // the innermost tracking element under the press). It is a tab stop so
+    // that when a pressed control goes away with the focus (Settings' close
+    // button, a form's Submit), the window root gives the focus back to it.
+    this.rootFocus = cx.focus_handle();
+    cx.spawn(async (cx) => followAppearance(cx));
+    // Every label is looked up while rendering, so a new language needs only
+    // a new frame. The app lives as long as the window, so this never ends.
+    onLanguageChange(() => this.cx.notify());
+    // Ages in the sidebar and turn footers are in minutes.
+    cx.spawn(async (cx) => {
+      for (;;) {
+        await cx.sleep(60000);
+        this.cx.notify();
+      }
+    });
+    cx.spawn(async (cx) => this.start(cx));
+  }
+
+  async start(cx) {
+    const dev = await import('./dev.js').then(
+      (module) => module.default ?? null,
+      () => null,
+    );
+    setDebug(dev?.debug === true);
+    log('start');
+    cx.bind_keys(SHORTCUTS);
+    this.rootFocus.focus();
+    this.composer.loadModel();
+    this.directories.loadRoots();
+    cx.spawn(async (cx) => this.catalog.run(cx));
+    if (typeof dev?.run === 'function') {
+      cx.spawn(async (cx) => {
+        try {
+          await dev.run({
+            app: this,
+            cx,
+            log: (message) => console.log(`maka-desktop dev: ${message}`),
+          });
+        } catch (error) {
+          this.report(`dev.js: ${error.message}`);
+        }
+      });
+    }
+  }
+
+  /** Where the window is: WORKHUB, SEARCH, the selected session's id, or null for the start pane. */
+  place() {
+    if (this.workhub.open) return WORKHUB;
+    if (this.search.open) return SEARCH;
+    return this.transcript?.id ?? null;
+  }
+
+  /**
+   * The conversation the composer sends to and Stop stops: WorkHub's while
+   * WorkHub is open, else the selected task's.
+   */
+  current() {
+    return this.workhub.open ? this.workhub.transcript : this.transcript;
+  }
+
+  /** Records a move from the current place, for Back. */
+  visit(next) {
+    const current = this.place();
+    if (next === current) return;
+    this.history.back.push(current);
+    this.history.forward = [];
+  }
+
+  /** Goes one place back (`direction` -1) or forward (1), skipping tasks that are gone. */
+  async go(direction) {
+    const [from, to] =
+      direction < 0
+        ? [this.history.back, this.history.forward]
+        : [this.history.forward, this.history.back];
+    const current = this.place();
+    while (from.length > 0) {
+      const place = from.pop();
+      // A removed task is skipped, and so is the place shown now, which the
+      // skip can bring next to itself (A, X, A with X removed).
+      if (place === current) continue;
+      if (place === WORKHUB) {
+        to.push(current);
+        await this.openWorkHub(false);
+        return;
+      }
+      if (place === SEARCH) {
+        to.push(current);
+        await this.openSearch(false);
+        return;
+      }
+      const session =
+        place === null ? null : this.catalog.sessions.find((entry) => entry.id === place);
+      if (place !== null && session === undefined) continue;
+      to.push(current);
+      if (session === null) await this.home(false);
+      else await this.select(session, false);
+      return;
+    }
+    this.cx.notify();
+  }
+
+  toggleSidebar() {
+    this.sidebarHidden = !this.sidebarHidden;
+    this.cx.notify();
+  }
+
+  /**
+   * Switches the transcript, leaving WorkHub or the Search page; the previous
+   * one's watch is closed first. With `anchor` (a transcript row, from a
+   * Search passage) it opens at that row, also when the task is open already.
+   */
+  async select(session, record = true, anchor = null) {
+    if (this.settings.open) this.settings.hide();
+    if (this.place() === session.id && anchor === null) return;
+    if (record) this.visit(session.id);
+    const previous = this.transcript;
+    const transcript = new Transcript(session, this.hooks, 'agent', { anchor });
+    this.transcript = transcript;
+    const leaving = this.workhub.hide();
+    this.search.hide();
+    log(`select "${session.title}"${anchor === null ? '' : ` at row ${anchor}`}`);
+    this.cx.notify();
+    if (previous !== null) await previous.close();
+    await leaving;
+    await transcript.open();
+  }
+
+  /** Leaves the selected task or WorkHub for the start pane, where a message starts a new one. */
+  async home(record = true) {
+    if (this.settings.open) this.settings.hide();
+    if (record) this.visit(null);
+    const previous = this.transcript;
+    this.transcript = null;
+    const leaving = this.workhub.hide();
+    this.search.hide();
+    this.cx.notify();
+    if (previous !== null) await previous.close();
+    await leaving;
+  }
+
+  /** Shows WorkHub in the plate; the selected task's transcript closes, as `select` closes it. */
+  async openWorkHub(record = true) {
+    if (this.settings.open) this.settings.hide();
+    if (this.workhub.open) return;
+    if (record) this.visit(WORKHUB);
+    const previous = this.transcript;
+    this.transcript = null;
+    this.search.hide();
+    log('open WorkHub');
+    const showing = this.workhub.show();
+    this.cx.notify();
+    if (previous !== null) await previous.close();
+    await showing;
+  }
+
+  /**
+   * Shows the Search page in the plate, as it was left: WorkHub hides and
+   * the selected task's transcript closes, as `openWorkHub` does.
+   */
+  async openSearch(record = true) {
+    if (this.settings.open) this.settings.hide();
+    if (this.search.open) return;
+    if (record) this.visit(SEARCH);
+    const previous = this.transcript;
+    this.transcript = null;
+    const leaving = this.workhub.hide();
+    this.search.show();
+    log('open Search');
+    this.cx.notify();
+    if (previous !== null) await previous.close();
+    await leaving;
+  }
+
+  /**
+   * The sidebar's search button and ⇧⌘F: the Search page, with the keyboard
+   * in its field and the field's text selected. On the page already, only
+   * the field is focused.
+   */
+  async startSearch() {
+    const opening = this.openSearch();
+    this.search.focusField();
+    await opening;
+  }
+
+  /** Selects a session WorkHub routed a task to: from the catalog, else as the Host observes it. */
+  async openSession(sessionId) {
+    let session = this.catalog.sessions.find((entry) => entry.id === sessionId);
+    if (session === undefined) {
+      try {
+        session = (await query('agent', 'observe', { sessionId })).session;
+      } catch (error) {
+        this.workhub.fail(t('workhubOpenFailed', { value: error.message }));
+        return;
+      }
+    }
+    await this.select(session);
+  }
+
+  /**
+   * Starts a session, as the TUI's "New" does: in the workspace of `session`
+   * (the selected one when not given), or in `directory` when given. The
+   * title comes from the first message. Resolves with the new session's
+   * transcript, or null.
+   */
+  async newSession(directory = null, session = null) {
+    const sessionId = session?.id ?? this.transcript?.id ?? null;
+    const workspace =
+      directory !== null
+        ? { kind: 'directory', selection: directory.selection }
+        : sessionId !== null
+          ? { kind: 'session', sessionId }
+          : null;
+    if (workspace === null || this.creating) return null;
+    this.creating = true;
+    this.cx.notify();
+    try {
+      const created = await operation('agent', 'create', {
+        // Stored in the Host until the automatic title replaces it, so it
+        // stays in the language the task was started in.
+        title: t('newTask'),
+        automaticTitle: true,
+        labels: [],
+        workspace,
+      });
+      if (created.kind !== 'created') {
+        throw new Error(t('hostRejectedTask', { value: JSON.stringify(created.reason) }));
+      }
+      log(`new session ${created.session.id} in ${workspace.kind}`);
+      await this.select(created.session);
+      return this.transcript;
+    } catch (error) {
+      this.report(t('newSessionFailed', { value: error.message }));
+      return null;
+    } finally {
+      this.creating = false;
+      this.cx.notify();
+    }
+  }
+
+  /** F2: renames the selected task in the sidebar, which shows its row first. */
+  renameSelected() {
+    if (this.settings.open || this.sidebarHidden || this.workhub.open || this.transcript === null) {
+      return;
+    }
+    const session = this.catalog.sessions.find((entry) => entry.id === this.transcript.id);
+    if (session === undefined) return;
+    this.sidebarUi.reveal(session, this.catalog.sessions);
+    this.sidebarUi.startRename(session);
+  }
+
+  /** Where a session started with no task selected works: the chosen folder, or the first root. */
+  startFolder() {
+    return this.directories.target();
+  }
+
+  report(message) {
+    console.error(`maka-desktop: ${message}`);
+    this.error = { message, at: new Date() };
+    this.cx.notify();
+  }
+
+  render(cx) {
+    const m = palette(cx);
+    const settings = this.settings.open;
+    // Settings keep their navigation in the sidebar's place even while the
+    // sidebar is hidden: it is the only way between their pages.
+    const column = settings || !this.sidebarHidden;
+    return h_flex()
+      .size_full()
+      .items_start()
+      .bg(m.canvas)
+      .text_color(m.ink)
+      .track_focus(this.rootFocus)
+      .tab_stop(true)
+      .on_action('maka.new-task', () => this.home())
+      .on_action('maka.toggle-sidebar', () => this.toggleSidebar())
+      .on_action('maka.back', () => this.go(-1))
+      .on_action('maka.forward', () => this.go(1))
+      .on_action('maka.settings', () => this.settings.show())
+      .on_action('maka.search', () => this.startSearch())
+      .on_action('maka.rename-task', () => this.renameSelected())
+      .on_action('maka.stop', () => {
+        const current = this.current();
+        if (current !== null) this.composer.stop(current);
+      })
+      .when(column, (el) =>
+        el.child(
+          v_flex()
+            .w(256)
+            .flex_none()
+            .h_full()
+            .child(this.chromeStrip(!settings, cx))
+            .child(
+              settings
+                ? settingsNav(this.settings, () => this.settings.hide(), cx)
+                : sidebar(
+                    this.catalog,
+                    this.sidebarUi,
+                    this.transcript?.id ?? null,
+                    {
+                      select: (session) => this.select(session),
+                      newTask: () => this.home(),
+                      workhub: () => this.openWorkHub(),
+                      search: () => this.startSearch(),
+                      newIn: (session) => this.newSession(null, session),
+                      settings: () => this.settings.show(),
+                      drafting: this.place() === null,
+                      inWorkHub: this.workhub.open,
+                      searching: this.search.open,
+                    },
+                    cx,
+                  ),
+            ),
+        ),
+      )
+      .child(
+        v_flex()
+          .flex_1()
+          .min_w_0()
+          .h_full()
+          .py(8)
+          .pr(8)
+          .when(!column, (el) => el.pl(8))
+          .child(
+            v_flex()
+              .size_full()
+              .rounded(12)
+              .bg(m.plate)
+              .overflow_hidden()
+              .when(settings, (el) => el.child(settingsPage(this.settings, cx)))
+              .when(!settings && this.workhub.open, (el) => this.workhubPane(el, cx))
+              .when(!settings && this.search.open, (el) => this.searchPane(el, cx))
+              .when(!settings && !this.workhub.open && !this.search.open, (el) =>
+                this.taskPane(el, cx),
+              ),
+          ),
+      );
+  }
+
+  /** The plate's content for a task or a new one: header, connection strip, then the conversation. */
+  taskPane(el, cx) {
+    const transcript = this.transcript;
+    const folder = transcript === null ? this.startFolder() : null;
+    return this.conversation(
+      el
+        .child(this.header(transcript?.session.title ?? '', runState(transcript), null, cx))
+        .child(this.connectionStrip(cx)),
+      transcript,
+      {
+        empty: transcript === null ? this.emptyState(cx) : null,
+        hint: null,
+        bar: null,
+        folder: folder === null ? null : { name: folder.name, path: folder.path },
+        picker:
+          transcript === null
+            ? folderPicker(
+                this.directories,
+                this.catalog.sessions,
+                this.creating || folder === null,
+                cx,
+              )
+            : null,
+      },
+      cx,
+    );
+  }
+
+  /**
+   * The plate's content while WorkHub is open: its header, with the tabs once
+   * it is set up, the connection strip, then its conversation, drawn as a
+   * task's, or its setup or tasks (workhub.js).
+   */
+  workhubPane(el, cx) {
+    const workhub = this.workhub;
+    const chat = workhub.chat();
+    el = el
+      .child(
+        this.header(t('workhubName'), runState(workhub.transcript), workhubTabs(workhub, cx), cx),
+      )
+      .child(this.connectionStrip(cx));
+    if (chat === null) {
+      return el.child(
+        workhubBody(workhub, { openSession: (sessionId) => this.openSession(sessionId) }, cx),
+      );
+    }
+    // Its folder was fixed when it was set up.
+    return this.conversation(
+      el,
+      chat,
+      {
+        empty: null,
+        hint: chatHint(cx),
+        bar: this.workhubNeedsSubagent() ? this.subagentBar(cx) : null,
+        folder: null,
+        picker: null,
+      },
+      cx,
+    );
+  }
+
+  /** The plate's content while the Search page shows: its header, the connection strip, then the page. */
+  searchPane(el, cx) {
+    return el
+      .child(this.header(t('search'), null, null, cx))
+      .child(this.connectionStrip(cx))
+      .child(searchPage(this.search, this.catalog.sessions, cx));
+  }
+
+  /**
+   * Whether WorkHub has no subagent to delegate with: its subagents and the
+   * models have been read, and none is enabled with a model the Host offers.
+   */
+  workhubNeedsSubagent() {
+    const presets = this.workhub.presets;
+    return (
+      presets !== null &&
+      this.composer.loaded &&
+      !presets.entries.some((entry) => usable(entry, this.composer.choices))
+    );
+  }
+
+  /**
+   * What a conversation fills the plate with under its header: the
+   * transcript, or `view.empty` before there is one; the card for what the
+   * agent waits on; the bar for a failed turn; and the composer. `view.hint`
+   * is what an empty transcript shows, null for the usual line; `view.bar` a
+   * bar above the composer when neither the card nor the failed turn's bar
+   * shows, or null; and `view.folder` and `view.picker` are the composer's
+   * (see composerPane).
+   */
+  conversation(el, transcript, view, cx) {
+    const card = transcript ? interactionCard(transcript.interactions, cx) : null;
+    const failed = transcript !== null && card === null ? transcript.failedTurn() : null;
+    const bar = card === null && failed === null ? view.bar : null;
+    return el
+      .when(transcript === null, (el) => el.child(view.empty))
+      .when(transcript !== null, (el) =>
+        el.child(transcriptPane(transcript, () => transcript.loadOlder(), cx, view.hint)),
+      )
+      .when(card !== null, (el) => el.child(card))
+      .when(failed !== null, (el) => el.child(this.failedBar(transcript, failed, cx)))
+      .when(bar !== null, (el) => el.child(bar))
+      .child(
+        composerPane(
+          this.composer,
+          transcript,
+          {
+            folder: view.folder,
+            picker: view.picker,
+            // A session whose permissions were never changed still has the
+            // level the Host gives every new one.
+            defaultAccess:
+              this.catalog.sessions.find((session) => session.boundaryRevision === 0)?.permissions
+                ?.access ?? null,
+            notice: this.error?.message ?? null,
+            dismiss: () => {
+              this.error = null;
+              this.cx.notify();
+            },
+            addConnection: () => {
+              this.settings.show('connections');
+              this.settings.openAdd();
+            },
+          },
+          cx,
+        ),
+      );
+  }
+
+  /**
+   * Above the composer when the newest turn failed: says so and offers to
+   * send its message again. Picking the failed run up where it ended
+   * (`agent.resume`) is not offered yet.
+   */
+  failedBar(transcript, turn, cx) {
+    const m = palette(cx);
+    return composerBar(
+      icon('status-failed', 16, m.destructive),
+      t('turnFailedNote'),
+      turn.message === null
+        ? null
+        : controlButton('send-again', t('sendAgain'), 'outline', this.composer.sending, cx)
+            .tooltip(t('sendAgainTip'))
+            .on_click(() => this.composer.send(transcript, turn.message)),
+      cx,
+    );
+  }
+
+  /**
+   * Above the composer in WorkHub's conversation while it has no subagent to
+   * delegate with: says so, and opens Settings on the subagents.
+   */
+  subagentBar(cx) {
+    const m = palette(cx);
+    return composerBar(
+      icon('triangle-alert', 16, m.warning),
+      t('workhubNeedsSubagent'),
+      controlButton('workhub-subagents', t('workhubSetUpSubagents'), 'outline', false, cx).on_click(
+        () => this.settings.show('subagents'),
+      ),
+      cx,
+    );
+  }
+
+  /**
+   * The plate's body with no task selected, as the native client's empty
+   * state: one display line centered over the reading column. The composer's
+   * folder chip says where the task will run. Without a model, it also says
+   * that one is missing and opens Settings on the connections.
+   */
+  emptyState(cx) {
+    const m = palette(cx);
+    const noModel = this.composer.loaded && this.composer.model === null;
+    return v_flex()
+      .id('empty-state')
+      .flex_1()
+      .min_h_0()
+      .w_full()
+      .items_center()
+      .justify_center()
+      .px(24)
+      .py(32)
+      .gap(16)
+      .child(text(t('emptyStateTitle'), 'display', m.ink).max_w(720).text_center())
+      .when(noModel, (el) =>
+        el.child(
+          h_flex()
+            .max_w(720)
+            .px(16)
+            .py(12)
+            .gap(12)
+            .items_center()
+            .rounded(12)
+            .bg(m.warningTint)
+            .child(icon('triangle-alert', 16, m.warning))
+            .child(text(t('noModelYet'), 'body', m.ink))
+            .child(
+              controlButton(
+                'open-settings',
+                t('addModelInSettings'),
+                'primary',
+                false,
+                cx,
+              ).on_click(() => this.settings.show('connections')),
+            ),
+        ),
+      );
+  }
+
+  /**
+   * The plate's 48px header: `title`, and what its conversation is doing
+   * (`state`, or null). `controls`, when given, sit in the middle of the
+   * plate, with the state after them. With the sidebar hidden it also
+   * carries the window controls, clear of the traffic lights.
+   */
+  header(title, state, controls, cx) {
+    const m = palette(cx);
+    const hidden = this.sidebarHidden;
+    // The parts around the buttons move the window; the buttons are outside
+    // them, so a press on one never starts a move.
+    const side = () =>
+      h_flex().flex_1().min_w_0().h_full().gap(12).items_center().window_control_area('drag');
+    const stateText = state === null ? null : text(state, 'small', m.inkMuted).flex_none();
+    // The title's half: the window controls when the sidebar is hidden, then
+    // the title. Middle controls get a half of the same width after them.
+    const leading = h_flex()
+      .flex_1()
+      .min_w_0()
+      .h_full()
+      .items_center()
+      .when(hidden, (el) =>
+        el
+          .child(dragArea().w(LIGHTS_END).h_full())
+          .child(this.windowControls(cx))
+          .child(dragArea().w(8).h_full()),
+      )
+      .when(!hidden, (el) => el.child(dragArea().w(20).h_full()))
+      .child(
+        side()
+          .child(text(title, 'strong', m.ink).flex_1().min_w_0().truncate())
+          .when(controls === null && stateText !== null, (el) => el.child(stateText)),
+      );
+    return h_flex()
+      .flex_none()
+      .h(48)
+      .pr(12)
+      .items_center()
+      .child(leading)
+      .when(controls !== null, (el) =>
+        el.child(controls).child(
+          side()
+            .justify_end()
+            .when(stateText !== null, (el) => el.child(stateText)),
+        ),
+      );
+  }
+
+  /**
+   * The 56px strip above the sidebar: the traffic lights, then the window
+   * controls (`controls`). Settings leave the controls out, as the native
+   * client does: they act on the task view, which waits behind settings. The
+   * empty parts move the
+   * window; the buttons are outside the drag areas so a press on them never
+   * starts a move.
+   */
+  chromeStrip(controls, cx) {
+    return h_flex()
+      .flex_none()
+      .h(56)
+      .pt(8)
+      .items_center()
+      .child(dragArea().w(LIGHTS_END).h_full())
+      .when(controls, (el) => el.child(this.windowControls(cx)))
+      .child(dragArea().flex_1().h_full());
+  }
+
+  /** Sidebar toggle, Back and Forward: 28px ghost buttons, 2 apart. */
+  windowControls(cx) {
+    const m = palette(cx);
+    const control = (id, glyph, enabled, run) =>
+      ghostButton(id, cx)
+        .size(28)
+        .justify_center()
+        .rounded(8)
+        .disabled(!enabled)
+        .when(enabled, (el) => el.on_click(run))
+        .child(icon(glyph, 16, enabled ? m.inkMuted : m.inkDisabled));
+    return h_flex()
+      .flex_none()
+      .gap(2)
+      .items_center()
+      .child(
+        control('window-sidebar', 'sidebar', true, () => this.toggleSidebar()).tooltip(
+          this.sidebarHidden
+            ? t('showSidebar', { shortcut: '⌘B' })
+            : t('hideSidebar', { shortcut: '⌘B' }),
+        ),
+      )
+      .child(
+        control('window-back', 'chevron-left', this.history.back.length > 0, () =>
+          this.go(-1),
+        ).tooltip(t('goBack', { shortcut: '⌘[' })),
+      )
+      .child(
+        control('window-forward', 'chevron-right', this.history.forward.length > 0, () =>
+          this.go(1),
+        ).tooltip(t('goForward', { shortcut: '⌘]' })),
+      );
+  }
+
+  /**
+   * Above the transcript, in its column: "Starting…" while the Host is being
+   * reached, and a banner while it cannot be. The catalog retries on its own;
+   * Retry asks it to now.
+   */
+  connectionStrip(cx) {
+    const m = palette(cx);
+    const state = this.catalog.state;
+    if (state === 'live') return div();
+    const column = (child) =>
+      h_flex()
+        .flex_none()
+        .w_full()
+        .px(24)
+        .pb(8)
+        .justify_center()
+        .child(v_flex().w_full().max_w(720).child(child));
+    if (state === 'connecting') {
+      return column(
+        h_flex()
+          .h(32)
+          .gap(8)
+          .items_center()
+          .child(icon('status-running', 16, m.primary))
+          .child(text(t('starting'), 'body', m.inkMuted)),
+      );
+    }
+    return column(
+      h_flex()
+        .px(16)
+        .py(12)
+        .gap(12)
+        .items_start()
+        .rounded(12)
+        .bg(m.warningTint)
+        .child(icon('triangle-alert', 16, m.warning).mt(2))
+        .child(
+          v_flex()
+            .flex_1()
+            .min_w_0()
+            .gap(2)
+            .child(text(t('hostNotAnswering'), 'strong', m.ink))
+            .child(text(t('hostNotAnsweringDetail'), 'body', m.ink))
+            .when(this.error !== null, (el) =>
+              el.child(text(this.error.message, 'small', m.destructive).truncate()),
+            ),
+        )
+        .child(
+          controlButton('host-retry', t('retry'), 'quiet', false, cx)
+            .h(28)
+            .on_click(() => this.catalog.retryNow()),
+        ),
+    );
+  }
+}
+
+/** What a conversation is doing, as its header says it: waiting on the user, working, or null. */
+function runState(transcript) {
+  if (transcript === null) return null;
+  if (transcript.interactions.entries.length > 0) return t('needsYou');
+  return transcript.run ? t('working') : null;
+}
+
+/** A bar above the composer, in its column: a glyph, a line, and a button at its end, or none. */
+function composerBar(glyph, words, button, cx) {
+  const m = palette(cx);
+  return h_flex()
+    .flex_none()
+    .w_full()
+    .px(24)
+    .pt(8)
+    .justify_center()
+    .child(
+      h_flex()
+        .w_full()
+        .max_w(720)
+        .py(8)
+        .pl(12)
+        .pr(8)
+        .gap(8)
+        .items_center()
+        .rounded(12)
+        .border_1()
+        .border_color(m.border)
+        .bg(m.plate)
+        .child(glyph)
+        .child(text(words, 'body', m.ink).flex_1().min_w_0())
+        .when(button !== null, (el) => el.child(button)),
+    );
+}
+
+/** An empty part of the window chrome that moves the window. */
+function dragArea() {
+  return div().flex_none().window_control_area('drag');
+}
diff --git a/products/maka/desktop-shell/app/messages.js b/products/maka/desktop-shell/app/messages.js
new file mode 100644
index 0000000000..b084299cfc
--- /dev/null
+++ b/products/maka/desktop-shell/app/messages.js
@@ -0,0 +1,1178 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Every word the desktop shows, as [English, Simplified Chinese, Traditional
+// Chinese] (see locale.js). `{name}` marks a value filled in where the
+// message is used. Where the native client (maka-gpui, crates/shared/src/
+// copy.rs) or the TUI (plugins/shared/locale-messages.ts) already words the
+// same thing, the translations follow it.
+
+export const MESSAGES = {
+  // Window (main.js)
+  hostRejectedTask: [
+    'the Host rejected it ({value})',
+    'Host 拒绝创建({value})',
+    'Host 拒絕建立({value})',
+  ],
+  newSessionFailed: ['New session: {value}', '新建任务失败:{value}', '建立任務失敗:{value}'],
+  emptyStateTitle: ['What should we work on?', '要做点什么?', '要做點什麼?'],
+  noModelYet: ['No model is set up yet', '尚未设置模型', '尚未設定模型'],
+  addModelInSettings: ['Add a model in Settings', '在设置中添加模型', '在設定中新增模型'],
+  needsYou: ['Needs you', '待处理', '待處理'],
+  working: ['Working…', '正在处理…', '正在處理…'],
+  showSidebar: [
+    'Show sidebar ({shortcut})',
+    '展开侧边栏({shortcut})',
+    '展開側邊欄({shortcut})',
+  ],
+  hideSidebar: [
+    'Hide sidebar ({shortcut})',
+    '收起侧边栏({shortcut})',
+    '收起側邊欄({shortcut})',
+  ],
+  goBack: ['Back ({shortcut})', '后退({shortcut})', '後退({shortcut})'],
+  goForward: ['Forward ({shortcut})', '前进({shortcut})', '前進({shortcut})'],
+  starting: ['Starting…', '正在启动…', '正在啟動…'],
+  hostNotAnswering: ['The Maka Host is not answering', 'Maka Host 没有响应', 'Maka Host 沒有回應'],
+  hostNotAnsweringDetail: [
+    'Tasks shown may be out of date. The desktop keeps trying to reach it.',
+    '显示的任务可能不是最新的。桌面端会持续尝试连接。',
+    '顯示的任務可能不是最新的。桌面端會持續嘗試連線。',
+  ],
+  retry: ['Retry', '重试', '重試'],
+
+  // Sidebar (sessions.js)
+  sessionListFailed: ['Session list: {value}', '任务列表:{value}', '任務列表:{value}'],
+  groupToday: ['Today', '今天', '今天'],
+  groupYesterday: ['Yesterday', '昨天', '昨天'],
+  groupThisWeek: ['This week', '本周', '本週'],
+  groupEarlier: ['Earlier', '更早', '更早'],
+  noFolder: ['No folder', '无文件夹', '無資料夾'],
+  newTask: ['New task', '新任务', '新任務'],
+  loadingTasks: ['Loading tasks…', '正在读取任务…', '正在讀取任務…'],
+  noTasksYet: ['No tasks yet', '还没有任务', '還沒有任務'],
+  groupByTime: ['By time', '按时间', '按時間'],
+  groupByProject: ['By project', '按项目', '按專案'],
+  newTaskIn: ['New task in {path}', '在 {path} 中新建任务', '在 {path} 中建立任務'],
+  showMore: ['Show more', '显示更多', '顯示更多'],
+  taskWorking: ['Working', '进行中', '進行中'],
+  disconnected: ['Disconnected', '未连接', '未連線'],
+  localHost: ['Local Host', '本机 Host', '本機 Host'],
+  settings: ['Settings', '设置', '設定'],
+  workhubName: ['WorkHub', 'WorkHub', 'WorkHub'],
+
+  // A task's menu in the sidebar, and what it does (sessions.js). The words
+  // follow the native client's menu and the TUI's session actions.
+  taskActions: ['Actions for {title}', '{title} 任务操作', '{title} 任務操作'],
+  renameTask: ['Rename', '重命名', '重新命名'],
+  copyTaskId: ['Copy task ID', '复制任务 ID', '複製任務 ID'],
+  archiveTask: ['Archive', '归档', '封存'],
+  taskNameTooLong: ['Name is too long.', '名称过长。', '名稱過長。'],
+  renameFailed: [
+    'Couldn’t rename the task: {value}',
+    '重命名任务失败:{value}',
+    '重新命名任務失敗:{value}',
+  ],
+  archiveFailed: [
+    'Couldn’t archive the task: {value}',
+    '归档任务失败:{value}',
+    '封存任務失敗:{value}',
+  ],
+  sessionChangeBusy: [
+    'Stop the task and clear its queued messages before archiving or deleting it.',
+    '请先停止此任务并处理排队消息,再归档或删除。',
+    '請先停止此任務並處理佇列訊息,再封存或刪除。',
+  ],
+
+  // Ages in lists (theme.js)
+  ageNow: ['now', '刚刚', '剛剛'],
+
+  // Composer (composer.js)
+  permissionMode: ['Permission mode', '权限模式', '權限模式'],
+  permissionReadOnly: ['Read only', '只读', '只讀'],
+  permissionReadOnlyHint: [
+    'Read and search only; asks before write or network.',
+    '只读搜索,不写文件、不上网;需要时先问你。',
+    '只讀搜尋,不寫檔案、不上網;需要時先問你。',
+  ],
+  permissionAuto: ['Auto', '自动', '自動'],
+  permissionAutoHint: [
+    'Runs inside Maka’s protection; asks before going further.',
+    '保护层内自动执行,越权先问你。',
+    '保護層內自動執行,越權先問你。',
+  ],
+  permissionFullAccess: ['Full access', '完全权限', '完全權限'],
+  permissionFullAccessHint: [
+    'Direct file and network access. Trust-only tasks.',
+    '直接访问文件和网络,仅限可信任务。',
+    '直接存取檔案和網路,僅限可信任務。',
+  ],
+  permissionFailed: [
+    'Couldn’t change the permission mode: {value}',
+    '切换权限模式失败:{value}',
+    '切換權限模式失敗:{value}',
+  ],
+  hostRefused: ['the Host refused ({value})', 'Host 拒绝了({value})', 'Host 拒絕了({value})'],
+  taskChangedMeanwhile: [
+    'the task changed meanwhile; try again',
+    '任务刚有变化,请再试一次',
+    '任務剛有變化,請再試一次',
+  ],
+  composerPlaceholder: ['Ask anything…', '有什么可以帮你?', '有什麼可以幫你?'],
+  modelListChangingReopen: [
+    'the model list is changing; reopen the app',
+    '模型列表正在变化,请重新打开应用',
+    '模型列表正在變更,請重新開啟應用程式',
+  ],
+  modelsFailed: ['Models: {value}', '模型:{value}', '模型:{value}'],
+  executorModel: [
+    'An executor (send from the terminal app)',
+    '执行器(请从终端应用发送)',
+    '執行器(請從終端機應用程式傳送)',
+  ],
+  noModel: ['No model', '无模型', '無模型'],
+  modelNotOffered: [
+    'the model {model} is not offered any more',
+    '模型 {model} 已不再提供',
+    '模型 {model} 已不再提供',
+  ],
+  executorCannotStart: [
+    'this session runs on an executor, which the desktop cannot start yet',
+    '此任务在执行器上运行,桌面端暂时无法启动它',
+    '此任務在執行器上執行,桌面端暫時無法啟動它',
+  ],
+  noModelConfigured: ['no model is configured', '尚未配置模型', '尚未設定模型'],
+  sessionModelNotOffered: [
+    "the session's model {model} is not offered any more",
+    '此任务的模型 {model} 已不再提供',
+    '此任務的模型 {model} 已不再提供',
+  ],
+  modelListChanging: [
+    'the model list is changing; try again',
+    '模型列表正在变化,请重试',
+    '模型列表正在變更,請重試',
+  ],
+  hostRejectedMessage: [
+    'the Host rejected the message ({value})',
+    'Host 拒绝了这条消息({value})',
+    'Host 拒絕了這則訊息({value})',
+  ],
+  sendFailed: ['Send: {value}', '发送失败:{value}', '傳送失敗:{value}'],
+  stopFailed: ['Stop: {value}', '停止失败:{value}', '停止失敗:{value}'],
+  stop: ['Stop', '停止', '停止'],
+  send: ['Send', '发送', '傳送'],
+  addConnectionMenu: ['Add connection…', '添加连接…', '新增連線…'],
+  executorNote: [
+    'This task runs on an executor; send from the terminal app',
+    '此任务在执行器上运行,请从终端应用发送',
+    '此任務在執行器上執行,請從終端機應用程式傳送',
+  ],
+  noticeDismiss: ['{value} (click to dismiss)', '{value}(点击关闭)', '{value}(按一下關閉)'],
+  attachFiles: ['Attach files', '添加附件', '新增附件'],
+  attachFailed: ['Attach files: {value}', '添加附件失败:{value}', '新增附件失敗:{value}'],
+  attachmentTooLarge: [
+    '{name} is over 50 MB and was not attached.',
+    '{name} 超过 50 MB,未添加。',
+    '{name} 超過 50 MB,未新增。',
+  ],
+  attachmentUnreadable: [
+    'Couldn’t attach {name}: {value}',
+    '无法添加 {name}:{value}',
+    '無法新增 {name}:{value}',
+  ],
+  attachmentLimit: [
+    'A message takes at most {limit} files; the rest were not attached.',
+    '每条消息最多 {limit} 个附件,其余未添加。',
+    '每則訊息最多 {limit} 個附件,其餘未新增。',
+  ],
+  attachmentRejected: [
+    'The Host did not take {name}: {reason}',
+    'Host 未接收 {name}:{reason}',
+    'Host 未接收 {name}:{reason}',
+  ],
+  attachmentUploadFailed: [
+    'Couldn’t upload {name}: {value}',
+    '上传 {name} 失败:{value}',
+    '上傳 {name} 失敗:{value}',
+  ],
+  attachmentInvalidFile: [
+    'the file could not be read, or it changed after it was chosen',
+    '文件无法读取,或选择后已被修改',
+    '檔案無法讀取,或選擇後已被修改',
+  ],
+  attachmentClosed: ['the Host is closing', 'Host 正在关闭', 'Host 正在關閉'],
+  attachmentArchived: ['the task is archived', '任务已归档', '任務已封存'],
+
+  // Folder picker (directories.js)
+  choose: ['Choose', '选择', '選擇'],
+  chooseFolderFailed: [
+    'Choose a folder: {value}',
+    '选择文件夹失败:{value}',
+    '選擇資料夾失敗:{value}',
+  ],
+  folderNotOfferedNone: [
+    '{path} is not inside a folder this Host offers (none)',
+    '{path} 不在此 Host 提供的文件夹中(无)',
+    '{path} 不在此 Host 提供的資料夾中(無)',
+  ],
+  folderNotOffered: [
+    '{path} is not inside a folder this Host offers ({offered})',
+    '{path} 不在此 Host 提供的文件夹中({offered})',
+    '{path} 不在此 Host 提供的資料夾中({offered})',
+  ],
+  hostDoesNotList: [
+    'the Host does not list {name} in {path}',
+    'Host 在 {path} 中没有列出 {name}',
+    'Host 在 {path} 中沒有列出 {name}',
+  ],
+  hostAnswered: ['the Host answered {value}', 'Host 返回了 {value}', 'Host 回傳了 {value}'],
+  listKeptChanging: ['the list kept changing', '列表一直在变化', '列表一直在變更'],
+  projectsFailed: ['Projects: {value}', '项目:{value}', '專案:{value}'],
+  directoriesFailed: ['Directories: {value}', '目录:{value}', '目錄:{value}'],
+  chooseFolder: ['Choose a folder', '选择文件夹', '選擇資料夾'],
+  chooseFolderMenu: ['Choose a folder…', '选择文件夹…', '選擇資料夾…'],
+  folderNotFound: ['Folder not found', '文件夹不存在', '資料夾不存在'],
+
+  // Transcript (transcript.js)
+  transcriptFailed: ['Transcript: {value}', '对话记录:{value}', '對話記錄:{value}'],
+  liveUpdatesEnded: [
+    'Live updates for this session ended',
+    '此任务的实时更新已结束',
+    '此任務的即時更新已結束',
+  ],
+  liveUpdatesFailed: ['Live updates: {value}', '实时更新:{value}', '即時更新:{value}'],
+  sessionStateFailed: ['Session state: {value}', '任务状态:{value}', '任務狀態:{value}'],
+  earlierMessagesFailed: [
+    'Earlier messages: {value}',
+    '更早的消息:{value}',
+    '更早的訊息:{value}',
+  ],
+  noMessagesYet: ['No messages yet', '暂无消息', '暫無訊息'],
+  loading: ['Loading…', '正在加载…', '正在載入…'],
+  loadingEarlier: ['Loading earlier messages', '正在加载更早的消息', '正在載入更早的訊息'],
+  loadEarlierFailed: [
+    'Couldn’t load earlier messages.',
+    '无法加载更早的消息。',
+    '無法載入更早的訊息。',
+  ],
+  beginningOfTask: ['Beginning of task', '任务开始', '任務開始'],
+  thinking: ['Thinking…', '深度思考中…', '深度思考中…'],
+  thought: ['Thought', '已深度思考', '已深度思考'],
+  toolRead: ['Read', '读取', '讀取'],
+  toolWrite: ['Write', '写入', '寫入'],
+  toolEdit: ['Edit', '编辑', '編輯'],
+  toolPatch: ['Patch', '补丁', '修補'],
+  toolGlob: ['Glob', 'Glob', 'Glob'],
+  toolGrep: ['Grep', 'Grep', 'Grep'],
+  toolRun: ['Run', '运行', '執行'],
+  toolOutput: ['Output', '输出', '輸出'],
+  toolInput: ['Input', '输入', '輸入'],
+  toolStop: ['Stop', '停止', '停止'],
+  toolStateRunning: ['running', '运行中', '執行中'],
+  toolStateDone: ['done', '已完成', '已完成'],
+  toolStateFailed: ['failed', '失败', '失敗'],
+  toolStateStopped: ['stopped', '已中止', '已中止'],
+  toolStateNotRun: ['not run', '未执行', '未執行'],
+  runningInBackground: ['Running in the background', '正在后台运行', '正在背景執行'],
+  outputStdout: ['stdout', 'stdout', 'stdout'],
+  outputStderr: ['stderr', 'stderr', 'stderr'],
+  outputScrollback: ['scrollback', '终端历史', '終端機歷史'],
+  outputScreen: ['screen', '终端画面', '終端機畫面'],
+  notShown: ['… {size} not shown …', '… {size} 未显示 …', '… {size} 未顯示 …'],
+  bytesOne: ['{count} byte', '{count} 字节', '{count} 位元組'],
+  bytesOther: ['{count} bytes', '{count} 字节', '{count} 位元組'],
+  toolUnnamed: ['Tool', '工具', '工具'],
+  earlierOutputNotKept: ['Earlier output was not kept', '更早的输出未保留', '更早的輸出未保留'],
+  couldNotReadCall: ['Could not read this call', '无法读取此调用', '無法讀取此呼叫'],
+  running: ['Running…', '正在运行…', '正在執行…'],
+  noOutput: ['No output', '无输出', '無輸出'],
+  linesNotShownOne: ['… {count} line not shown …', '… {count} 行未显示 …', '… {count} 行未顯示 …'],
+  linesNotShownOther: [
+    '… {count} lines not shown …',
+    '… {count} 行未显示 …',
+    '… {count} 行未顯示 …',
+  ],
+  failed: ['Failed', '失败', '失敗'],
+  stopped: ['Stopped', '已中止', '已中止'],
+  copied: ['Copied', '已复制', '已複製'],
+  turnFailedNote: [
+    'The last run stopped on an error.',
+    '上一轮运行出错中断了。',
+    '上一輪執行出錯中斷了。',
+  ],
+  runError: ['Run error: {value}', '运行出错:{value}', '執行出錯:{value}'],
+  contextCompacted: [
+    'Context compressed ({seconds}s)',
+    '上下文已压缩({seconds}秒)',
+    '上下文已壓縮({seconds}秒)',
+  ],
+  sendAgain: ['Send again', '重新发送', '重新傳送'],
+  sendAgainTip: [
+    'Send the message that started this turn once more',
+    '把这一轮的消息再发送一次',
+    '把這一輪的訊息再傳送一次',
+  ],
+
+  // How long ago, in the transcript (transcript.js relativeTime)
+  justNow: ['just now', '刚刚', '剛剛'],
+  minutesAgoOne: ['{count} minute ago', '{count}分钟前', '{count} 分鐘前'],
+  minutesAgoOther: ['{count} minutes ago', '{count}分钟前', '{count} 分鐘前'],
+  hoursAgoOne: ['{count} hour ago', '{count}小时前', '{count} 小時前'],
+  hoursAgoOther: ['{count} hours ago', '{count}小时前', '{count} 小時前'],
+  yesterday: ['yesterday', '昨天', '昨天'],
+  twoDaysAgo: ['2 days ago', '前天', '前天'],
+  daysAgo: ['{count} days ago', '{count}天前', '{count} 天前'],
+  timeAm: ['AM', '上午', '上午'],
+  timePm: ['PM', '下午', '下午'],
+
+  // What the agent waits on (interactions.js)
+  allowOnce: ['Allow once', '允许一次', '允許一次'],
+  allowForTurn: ['Allow for this turn', '本轮内允许', '本輪內允許'],
+  allowForSession: ['Allow for this task', '本任务内允许', '本任務內允許'],
+  pendingRequestsFailed: [
+    'Pending requests: {value}',
+    '待处理请求:{value}',
+    '待處理請求:{value}',
+  ],
+  requestFailed: ['Request {id}: {value}', '请求 {id}:{value}', '請求 {id}:{value}'],
+  hostRejectedAnswer: [
+    'the Host rejected the answer ({value})',
+    'Host 拒绝了这个回答({value})',
+    'Host 拒絕了這個回答({value})',
+  ],
+  answerFailed: ['Answer: {value}', '回答失败:{value}', '回答失敗:{value}'],
+  placeholderNumber: ['A number', '数字', '數字'],
+  placeholderWholeNumber: ['A whole number', '整数', '整數'],
+  required: ['Required', '必填', '必填'],
+  answersTooLong: [
+    'The answers are too long to send. Shorten them and submit again.',
+    '回答太长,无法发送。请缩短后重新提交。',
+    '回答太長,無法傳送。請縮短後重新提交。',
+  ],
+  useAtLeastCharactersOne: [
+    'Use at least {count} character',
+    '至少输入 {count} 个字符',
+    '至少輸入 {count} 個字元',
+  ],
+  useAtLeastCharactersOther: [
+    'Use at least {count} characters',
+    '至少输入 {count} 个字符',
+    '至少輸入 {count} 個字元',
+  ],
+  useAtMostCharactersOne: [
+    'Use at most {count} character',
+    '最多输入 {count} 个字符',
+    '最多輸入 {count} 個字元',
+  ],
+  useAtMostCharactersOther: [
+    'Use at most {count} characters',
+    '最多输入 {count} 个字符',
+    '最多輸入 {count} 個字元',
+  ],
+  enterWholeNumber: ['Enter a whole number', '请输入整数', '請輸入整數'],
+  enterNumber: ['Enter a number', '请输入数字', '請輸入數字'],
+  enterEmail: ['Enter an email address', '请输入电子邮件地址', '請輸入電子郵件地址'],
+  enterFullAddress: [
+    'Enter a full address, such as https://example.com',
+    '请输入完整地址,例如 https://example.com',
+    '請輸入完整網址,例如 https://example.com',
+  ],
+  enterDate: [
+    'Enter a date as YYYY-MM-DD',
+    '请按 YYYY-MM-DD 格式输入日期',
+    '請以 YYYY-MM-DD 格式輸入日期',
+  ],
+  enterDateTime: [
+    'Enter a date and time as YYYY-MM-DDThh:mm:ssZ',
+    '请按 YYYY-MM-DDThh:mm:ssZ 格式输入日期和时间',
+    '請以 YYYY-MM-DDThh:mm:ssZ 格式輸入日期和時間',
+  ],
+  numberFromTo: [
+    'A number from {low} to {high}',
+    '{low} 到 {high} 之间的数字',
+    '{low} 到 {high} 之間的數字',
+  ],
+  enterNumberFromTo: [
+    'Enter a number from {low} to {high}',
+    '请输入 {low} 到 {high} 之间的数字',
+    '請輸入 {low} 到 {high} 之間的數字',
+  ],
+  numberAtLeast: ['A number of at least {low}', '不小于 {low} 的数字', '不小於 {low} 的數字'],
+  enterNumberAtLeast: [
+    'Enter a number of at least {low}',
+    '请输入不小于 {low} 的数字',
+    '請輸入不小於 {low} 的數字',
+  ],
+  numberAtMost: ['A number of at most {high}', '不大于 {high} 的数字', '不大於 {high} 的數字'],
+  enterNumberAtMost: [
+    'Enter a number of at most {high}',
+    '请输入不大于 {high} 的数字',
+    '請輸入不大於 {high} 的數字',
+  ],
+  wholeNumberFromTo: [
+    'A whole number from {low} to {high}',
+    '{low} 到 {high} 之间的整数',
+    '{low} 到 {high} 之間的整數',
+  ],
+  enterWholeNumberFromTo: [
+    'Enter a whole number from {low} to {high}',
+    '请输入 {low} 到 {high} 之间的整数',
+    '請輸入 {low} 到 {high} 之間的整數',
+  ],
+  wholeNumberAtLeast: [
+    'A whole number of at least {low}',
+    '不小于 {low} 的整数',
+    '不小於 {low} 的整數',
+  ],
+  enterWholeNumberAtLeast: [
+    'Enter a whole number of at least {low}',
+    '请输入不小于 {low} 的整数',
+    '請輸入不小於 {low} 的整數',
+  ],
+  wholeNumberAtMost: [
+    'A whole number of at most {high}',
+    '不大于 {high} 的整数',
+    '不大於 {high} 的整數',
+  ],
+  enterWholeNumberAtMost: [
+    'Enter a whole number of at most {high}',
+    '请输入不大于 {high} 的整数',
+    '請輸入不大於 {high} 的整數',
+  ],
+  chooseOptionsOne: ['Choose {count} option', '选择 {count} 项', '選擇 {count} 項'],
+  chooseOptionsOther: ['Choose {count} options', '选择 {count} 项', '選擇 {count} 項'],
+  chooseAtMostOptionsOne: [
+    'Choose at most {count} option',
+    '最多选择 {count} 项',
+    '最多選擇 {count} 項',
+  ],
+  chooseAtMostOptionsOther: [
+    'Choose at most {count} options',
+    '最多选择 {count} 项',
+    '最多選擇 {count} 項',
+  ],
+  chooseOptionsRangeOne: [
+    'Choose {low} to {count} option',
+    '选择 {low} 到 {count} 项',
+    '選擇 {low} 到 {count} 項',
+  ],
+  chooseOptionsRangeOther: [
+    'Choose {low} to {count} options',
+    '选择 {low} 到 {count} 项',
+    '選擇 {low} 到 {count} 項',
+  ],
+  allowServiceCall: ['Allow this service call?', '允许这次服务调用吗?', '允許這次服務呼叫嗎?'],
+  serviceCallDetail: ['{tool} calls {call}', '{tool} 调用 {call}', '{tool} 呼叫 {call}'],
+  allowCapability: ['Allow this capability?', '允许使用这项能力吗?', '允許使用這項能力嗎?'],
+  capabilityDetail: [
+    '{tool} uses {capability}',
+    '{tool} 使用 {capability}',
+    '{tool} 使用 {capability}',
+  ],
+  allowMoreAccess: ['Allow more access?', '允许更多访问权限吗?', '允許更多存取權限嗎?'],
+  deny: ['Deny', '拒绝', '拒絕'],
+  agentAsks: ['The agent asks', 'Agent 有一个问题', 'Agent 有一個問題'],
+  agentAsksSeveral: ['The agent asks a few things', 'Agent 有几个问题', 'Agent 有幾個問題'],
+  submit: ['Submit', '提交', '提交'],
+  agentNeedsDetails: ['The agent needs some details', 'Agent 需要一些信息', 'Agent 需要一些資訊'],
+  fieldProblem: ['{label}: {problem}', '{label}:{problem}', '{label}:{problem}'],
+  decline: ['Decline', '拒绝填写', '拒絕填寫'],
+  cancelRequest: ['Cancel request', '取消请求', '取消請求'],
+  agentWaitingUnknown: [
+    'The agent is waiting for something the desktop cannot show',
+    'Agent 正在等待桌面端无法显示的内容',
+    'Agent 正在等待桌面端無法顯示的內容',
+  ],
+  answerInTerminal: [
+    'A "{kind}" request; answer it in the terminal app (maka).',
+    '这是「{kind}」类请求,请在终端应用(maka)中回答。',
+    '這是「{kind}」類請求,請在終端機應用程式(maka)中回答。',
+  ],
+  moreWaiting: ['{count} more waiting', '还有 {count} 个在等待', '還有 {count} 個在等待'],
+  answering: ['Answering…', '正在回答…', '正在回答…'],
+  waitingForAnswer: ['Waiting for your answer', '等待你的回答', '等待你的回答'],
+  requestUnreadable: [
+    'The agent is waiting, but its request could not be read',
+    'Agent 正在等待,但无法读取它的请求',
+    'Agent 正在等待,但無法讀取它的請求',
+  ],
+  tryAgain: ['Try again', '重试', '重試'],
+  allow: ['Allow', '允许', '允許'],
+  capabilityOnOrigin: [
+    '{capability} on {origin}',
+    '{origin} 上的 {capability}',
+    '{origin} 上的 {capability}',
+  ],
+  commandIn: ['in {path}', '位于 {path}', '位於 {path}'],
+  fromRequester: ['From {name}', '来自 {name}', '來自 {name}'],
+  yes: ['Yes', '是', '是'],
+  no: ['No', '否', '否'],
+  fieldKindUnsupported: [
+    '{kind} fields cannot be filled in here',
+    '此处无法填写 {kind} 类型的字段',
+    '此處無法填寫 {kind} 類型的欄位',
+  ],
+  charactersRangeOne: [
+    '{low} to {count} character',
+    '{low} 到 {count} 个字符',
+    '{low} 到 {count} 個字元',
+  ],
+  charactersRangeOther: [
+    '{low} to {count} characters',
+    '{low} 到 {count} 个字符',
+    '{low} 到 {count} 個字元',
+  ],
+  atLeastCharactersOne: [
+    'At least {count} character',
+    '至少 {count} 个字符',
+    '至少 {count} 個字元',
+  ],
+  atLeastCharactersOther: [
+    'At least {count} characters',
+    '至少 {count} 个字符',
+    '至少 {count} 個字元',
+  ],
+  atMostCharactersOne: ['At most {count} character', '最多 {count} 个字符', '最多 {count} 個字元'],
+  atMostCharactersOther: [
+    'At most {count} characters',
+    '最多 {count} 个字符',
+    '最多 {count} 個字元',
+  ],
+  chooseAny: ['Choose any', '任选', '任選'],
+  skip: ['Skip', '跳过', '跳過'],
+
+  // Settings: changes and their failures (settings.js)
+  keyRejectedRequest: ['the request was not valid', '请求无效', '請求無效'],
+  keyRejectedSecret: [
+    'the key is empty, too long, or has control characters',
+    '密钥为空、过长或包含控制字符',
+    '金鑰為空、過長或包含控制字元',
+  ],
+  keyRejectedUnreadable: ['the key could not be read', '无法读取密钥', '無法讀取金鑰'],
+  keyRejectedProvider: [
+    'this Host does not offer the provider',
+    '此 Host 不提供该服务商',
+    '此 Host 不提供該服務商',
+  ],
+  keyRejectedNoKeys: [
+    'the provider does not take an API key',
+    '该服务商不接受 API Key',
+    '該服務商不接受 API Key',
+  ],
+  keyRejectedConflict: [
+    'another save was in progress; try again',
+    '另一次保存正在进行,请重试',
+    '另一次儲存正在進行,請重試',
+  ],
+  connectionChanged: [
+    'the connection changed meanwhile; try again',
+    '连接在此期间已变化,请重试',
+    '連線在此期間已變更,請重試',
+  ],
+  keyRejectedKeyChanged: [
+    'the key changed meanwhile; try again',
+    '密钥在此期间已变化,请重试',
+    '金鑰在此期間已變更,請重試',
+  ],
+  keyRejectedFull: ['the key store is full', '密钥存储已满', '金鑰儲存空間已滿'],
+  keyRejectedIdTaken: ['the connection ID is taken', '该连接标识已被占用', '該連線識別碼已被使用'],
+  keyRejectedAuthentication: [
+    'the provider did not accept the key',
+    '服务商未接受该密钥',
+    '服務商未接受該金鑰',
+  ],
+  findModel: ['Find a model', '搜索模型', '搜尋模型'],
+  findProvider: ['Find a provider', '搜索服务商', '搜尋服務商'],
+  fieldName: ['Name', '名称', '名稱'],
+  endpointUrl: ['Endpoint URL', '端点 URL', '端點 URL'],
+  apiKey: ['API key', 'API Key', 'API Key'],
+  settingsFailed: ['Settings: {value}', '设置:{value}', '設定:{value}'],
+  connectionListTooManyPages: [
+    'the connection list has too many pages',
+    '连接列表页数过多',
+    '連線列表頁數過多',
+  ],
+  connectionListKeptChanging: [
+    'the connection list kept changing; try again',
+    '连接列表一直在变化,请重试',
+    '連線列表一直在變更,請重試',
+  ],
+  noProviderDescription: [
+    'no description of provider {name}',
+    '没有服务商 {name} 的描述',
+    '沒有服務商 {name} 的描述',
+  ],
+  providerListTooManyPages: [
+    'the provider list has too many pages',
+    '服务商列表页数过多',
+    '服務商列表頁數過多',
+  ],
+  modelListKeptChanging: [
+    'the model list kept changing; try again',
+    '模型列表一直在变化,请重试',
+    '模型列表一直在變更,請重試',
+  ],
+  settingDefaultFailed: [
+    'Setting the default model: {value}',
+    '设置默认模型失败:{value}',
+    '設定預設模型失敗:{value}',
+  ],
+  settingDefault: ['Setting the default model…', '正在设置默认模型…', '正在設定預設模型…'],
+  providersFailed: ['Providers: {value}', '服务商列表:{value}', '服務商列表:{value}'],
+  providerFailed: ['Provider: {value}', '服务商:{value}', '服務商:{value}'],
+  addingConnectionFailed: [
+    'Adding {name}: {value}',
+    '添加 {name} 失败:{value}',
+    '新增 {name} 失敗:{value}',
+  ],
+  addingConnection: ['Adding {name}…', '正在添加 {name}…', '正在新增 {name}…'],
+  enterApiKey: ['Enter the API key', '请输入 API Key', '請輸入 API Key'],
+  savingKeyFailed: ['Saving the key: {value}', '保存密钥失败:{value}', '儲存金鑰失敗:{value}'],
+  savingKey: ['Saving the key…', '正在保存密钥…', '正在儲存金鑰…'],
+  keySavedThen: ['Key saved; {value}', '密钥已保存;{value}', '金鑰已儲存;{value}'],
+  removingConnectionFailed: [
+    'Removing the connection: {value}',
+    '移除连接失败:{value}',
+    '移除連線失敗:{value}',
+  ],
+  removingConnection: ['Removing the connection…', '正在移除连接…', '正在移除連線…'],
+  findingModelsFailed: [
+    'Finding models: {value}',
+    '查找模型失败:{value}',
+    '尋找模型失敗:{value}',
+  ],
+  findingModels: ['Finding models…', '正在查找模型…', '正在尋找模型…'],
+  creatingConnectionStep: ['Creating the connection', '正在创建连接', '正在建立連線'],
+  hostCreatedNoConnection: [
+    'the Host created no connection',
+    'Host 没有创建连接',
+    'Host 沒有建立連線',
+  ],
+  added: ['Added', '已添加', '已新增'],
+  savingKeyStep: ['Saving the key', '正在保存密钥', '正在儲存金鑰'],
+  addedWithoutKey: [
+    'added without a key: {value}',
+    '已添加但未保存密钥:{value}',
+    '已新增但未儲存金鑰:{value}',
+  ],
+  keySaved: ['Key saved', '密钥已保存', '金鑰已儲存'],
+  findingModelsStep: ['Finding models', '正在查找模型', '正在尋找模型'],
+  addedThen: ['Added; {value}', '已添加;{value}', '已新增;{value}'],
+  hostRefusedKey: [
+    'the Host refused the key ({value})',
+    'Host 拒绝了该密钥({value})',
+    'Host 拒絕了該金鑰({value})',
+  ],
+  modelsEnabledOne: ['{count} model enabled', '已启用 {count} 个模型', '已啟用 {count} 個模型'],
+  modelsEnabledOther: ['{count} models enabled', '已启用 {count} 个模型', '已啟用 {count} 個模型'],
+  changedWhileFinding: [
+    'the connection changed while finding models; use Find models again',
+    '查找模型时连接发生了变化,请再次使用「查找模型」',
+    '尋找模型時連線發生了變更,請再次使用「尋找模型」',
+  ],
+  findingModelsFailedHint: [
+    'finding models failed; check the key and the endpoint, then use Find models',
+    '查找模型失败,请检查密钥和端点,然后使用「查找模型」',
+    '尋找模型失敗,請檢查金鑰和端點,然後使用「尋找模型」',
+  ],
+  connectionGone: ['the connection is gone', '该连接已不存在', '該連線已不存在'],
+  importPrompt: ['Import', '导入', '匯入'],
+  importFailed: ['Import: {value}', '导入:{value}', '匯入:{value}'],
+  importingFailed: ['Importing: {value}', '导入失败:{value}', '匯入失敗:{value}'],
+  importing: ['Importing…', '正在导入…', '正在匯入…'],
+  notJson: ['the file is not JSON', '该文件不是 JSON', '該檔案不是 JSON'],
+  notSettingsExport: [
+    'the file is not a Maka settings export with keys (schema version 1)',
+    '该文件不是包含密钥的 Maka 设置导出文件(schema 版本 1)',
+    '該檔案不是包含金鑰的 Maka 設定匯出檔案(schema 版本 1)',
+  ],
+  onlyApiKeys: ['only API keys can be imported', '只能导入 API Key', '只能匯入 API Key'],
+  hostHasNoProvider: [
+    'this Host has no provider "{name}"',
+    '此 Host 没有服务商「{name}」',
+    '此 Host 沒有服務商「{name}」',
+  ],
+  invalidConnectionId: [
+    'its ID is not a valid connection ID',
+    '其 ID 不是有效的连接标识',
+    '其 ID 不是有效的連線識別碼',
+  ],
+  connectionIdExistsHere: [
+    'a connection with this ID exists',
+    '已有使用此标识的连接',
+    '已有使用此識別碼的連線',
+  ],
+  keyEmpty: ['the key is empty', '密钥为空', '金鑰為空'],
+  enterName: [
+    'Enter a name of at most 256 characters',
+    '请输入不超过 256 个字符的名称',
+    '請輸入不超過 256 個字元的名稱',
+  ],
+  enterEndpoint: ['Enter the endpoint URL', '请输入端点 URL', '請輸入端點 URL'],
+  providerSignsInTerminal: [
+    'This provider signs in through the terminal app (maka) for now',
+    '该服务商目前需在终端应用(maka)中登录',
+    '該服務商目前需在終端機應用程式(maka)中登入',
+  ],
+  connectionIdExists: [
+    'a connection with ID {slug} exists',
+    '已存在标识为 {slug} 的连接',
+    '已存在識別碼為 {slug} 的連線',
+  ],
+  settingsChanged: [
+    'the settings changed meanwhile; try again',
+    '设置在此期间已变化,请重试',
+    '設定在此期間已變更,請重試',
+  ],
+  invalidDefaultModel: [
+    'that model cannot be the default',
+    '该模型不能设为默认',
+    '該模型不能設為預設',
+  ],
+  hostRefusedIt: [
+    'the Host refused it ({value})',
+    'Host 拒绝了此操作({value})',
+    'Host 拒絕了此操作({value})',
+  ],
+  unexpectedAnswer: ['unexpected answer {value}', '意外的回应 {value}', '非預期的回應 {value}'],
+
+  // Settings: pages and navigation (settings.js)
+  settingsGeneral: ['General', '通用', '通用'],
+  settingsGeneralDetail: [
+    'The language and look of the app.',
+    '应用的语言和外观。',
+    '應用程式的語言和外觀。',
+  ],
+  settingsModels: ['Models', '模型', '模型'],
+  settingsModelsDetail: [
+    'The model new tasks start with, from your connections.',
+    '新任务默认使用的模型,来自已添加的连接。',
+    '新任務預設使用的模型,來自已新增的連線。',
+  ],
+  settingsConnections: ['Connections', '模型连接', '模型連線'],
+  settingsConnectionsDetail: [
+    'Model providers and their API keys.',
+    '模型服务商及其 API Key。',
+    '模型服務商及其 API Key。',
+  ],
+  settingsSubagents: ['Subagents', '子代理', '子代理'],
+  settingsSubagentsDetail: [
+    'Save reusable worker configurations for WorkHub. Each task keeps its original model and tool selection; later edits affect new tasks only.',
+    '为 WorkHub 保存可复用的代理配置。任务会保留派发时的模型和工具选择,后续修改只影响新任务。',
+    '為 WorkHub 儲存可重用的代理設定。任務會保留派發時的模型和工具選擇,後續修改只影響新任務。',
+  ],
+  settingsArchived: ['Archived', '已归档', '已封存'],
+  settingsArchivedDetail: [
+    'Archived tasks keep their history. Restore one to see it in the sidebar again.',
+    '已归档的任务会保留历史记录。恢复后会重新出现在侧栏。',
+    '已封存的任務會保留歷史紀錄。還原後會重新出現在側欄。',
+  ],
+  backToApp: ['Back to app', '返回应用', '返回應用'],
+  importFromExport: ['Import from Maka export…', '从 Maka 导出文件导入…', '從 Maka 匯出檔案匯入…'],
+  importFromExportTip: [
+    'Read the API keys from a settings file exported by the old Maka app',
+    '从旧版 Maka 应用导出的设置文件中读取 API Key',
+    '從舊版 Maka 應用程式匯出的設定檔中讀取 API Key',
+  ],
+  addConnection: ['Add connection', '添加连接', '新增連線'],
+
+  // Settings: General (settings.js)
+  settingsLanguage: ['Interface language', '界面语言', '介面語言'],
+  settingsLanguageDetail: [
+    'Choose the language used by Maka. Changes apply immediately and persist after restart.',
+    '选择 Maka 界面的显示语言。切换后立即生效,重启后保持。',
+    '選擇 Maka 介面的顯示語言。切換後立即生效,重新啟動後仍會保留。',
+  ],
+  languageSystem: ['Follow system', '跟随系统', '自動(跟隨系統)'],
+  settingsAppearance: ['Theme', '主题', '主題'],
+  settingsAppearanceDetail: [
+    'Follow the system appearance, or stay on light or dark.',
+    '界面跟随系统,还是固定浅色或深色。',
+    '介面跟隨系統,還是固定淺色或深色。',
+  ],
+  appearance_system: ['Follow system', '跟随系统', '跟隨系統'],
+  appearance_light: ['Light', '浅色', '淺色'],
+  appearance_dark: ['Dark', '深色', '深色'],
+
+  // Settings: Models (settings.js)
+  defaultUnavailable: ['Unavailable; choose another', '不可用,请另选一个', '無法使用,請另選一個'],
+  defaultNone: [
+    'None; new tasks use the first model listed',
+    '未设置;新任务使用列表中的第一个模型',
+    '未設定;新任務使用列表中的第一個模型',
+  ],
+  defaultModel: ['Default model', '默认模型', '預設模型'],
+  defaultBadge: ['Default', '默认', '預設'],
+  useAsDefault: ['Use as default', '设为默认', '設為預設'],
+  noModelsYet: [
+    'No models yet. Add a connection with a key.',
+    '还没有模型。请添加一个带密钥的连接。',
+    '還沒有模型。請新增一個帶金鑰的連線。',
+  ],
+  defaultModelDetail: [
+    'New tasks start with this model; the composer can pick another.',
+    '新任务默认使用此模型;输入框中可以另选。',
+    '新任務預設使用此模型;輸入框中可以另選。',
+  ],
+
+  // Settings: Connections (settings.js)
+  noConnectionsYet: ['No connections yet.', '暂无连接。', '尚無連線。'],
+  keyChecking: ['Key: …', '密钥:…', '金鑰:…'],
+  keyStatusUnknown: ['Key status unknown', '密钥状态未知', '金鑰狀態不明'],
+  noKeyNeeded: ['No key needed', '无需密钥', '無需金鑰'],
+  noKey: ['No key', '未设置密钥', '未設定金鑰'],
+  modelCountOne: ['{count} model', '{count} 个模型', '{count} 個模型'],
+  modelCountOther: ['{count} models', '{count} 个模型', '{count} 個模型'],
+  turnedOff: ['Turned off', '已停用', '已停用'],
+  setKey: ['Set key', '设置密钥', '設定金鑰'],
+  findModelsButton: ['Find models', '查找模型', '尋找模型'],
+  findModelsTip: [
+    'Ask the provider which models it offers and enable them',
+    '向服务商查询它提供的模型并启用',
+    '向服務商查詢它提供的模型並啟用',
+  ],
+  remove: ['Remove', '移除', '移除'],
+  confirmRemoveDefault: [
+    'Remove this connection, its API keys and the default model? Tasks that used it are kept.',
+    '移除此连接、它的 API Key 和默认模型?使用过它的任务会保留。',
+    '移除此連線、它的 API Key 和預設模型?使用過它的任務會保留。',
+  ],
+  confirmRemove: [
+    'Remove this connection and its API keys? Tasks that used it are kept.',
+    '移除此连接及其 API Key?使用过它的任务会保留。',
+    '移除此連線及其 API Key?使用過它的任務會保留。',
+  ],
+  cancel: ['Cancel', '取消', '取消'],
+  saveKey: ['Save key', '保存密钥', '儲存金鑰'],
+  provider: ['Provider', '服务商', '服務商'],
+  listingProviders: ['Listing providers…', '正在列出服务商…', '正在列出服務商…'],
+  moreProviders: [
+    '{count} more; type to narrow the list',
+    '还有 {count} 个;输入文字以缩小范围',
+    '還有 {count} 個;輸入文字以縮小範圍',
+  ],
+  addAConnection: ['Add a connection', '添加连接', '新增連線'],
+  chooseProvider: ['Choose the provider', '选择服务商', '選擇服務商'],
+  endpoint: ['Endpoint', '端点', '端點'],
+  apiKeyOptional: ['API key (optional)', 'API Key(可选)', 'API Key(選填)'],
+  keptByHost: [
+    'Kept by the Host; never shown again',
+    '由 Host 保管,不会再次显示',
+    '由 Host 保管,不會再次顯示',
+  ],
+  providerSignsInTerminalNote: [
+    'This provider signs in through the terminal app (maka) for now.',
+    '该服务商目前需在终端应用(maka)中登录。',
+    '該服務商目前需在終端機應用程式(maka)中登入。',
+  ],
+  add: ['Add', '添加', '新增'],
+  addProviderConnection: [
+    'Add a {provider} connection',
+    '添加 {provider} 连接',
+    '新增 {provider} 連線',
+  ],
+  changeProvider: ['Change provider', '更换服务商', '更換服務商'],
+
+  // Settings: import from an export (settings.js)
+  importReady: ['Will be imported', '将导入', '將匯入'],
+  importSkipped: ['Skipped', '已跳过', '已跳過'],
+  importWorking: ['Importing', '正在导入', '正在匯入'],
+  importDone: ['Imported', '已导入', '已匯入'],
+  importRowTitle: ['{name} ({id})', '{name}({id})', '{name}({id})'],
+  importRowTitleNoId: ['{name} (no ID)', '{name}(无 ID)', '{name}(無 ID)'],
+  fileHasNoKeys: ['The file has no keys.', '该文件中没有密钥。', '該檔案中沒有金鑰。'],
+  close: ['Close', '关闭', '關閉'],
+  importKeysOne: ['Import {count} key', '导入 {count} 个密钥', '匯入 {count} 個金鑰'],
+  importKeysOther: ['Import {count} keys', '导入 {count} 个密钥', '匯入 {count} 個金鑰'],
+  importFrom: ['Import from {name}', '从 {name} 导入', '從 {name} 匯入'],
+
+  // Settings: Subagents (subagents.js). Where the TUI's page
+  // (plugins/shared/tui-messages.ts) words the same thing, these copy it.
+  subagentsNew: ['New subagent', '新建子代理', '新增子代理'],
+  subagentsReadFailed: [
+    'Couldn’t read the subagents: {value}',
+    '读取子代理失败:{value}',
+    '讀取子代理失敗:{value}',
+  ],
+  subagentsEmpty: [
+    'No subagents yet. WorkHub uses a subagent to decide which model and tools a delegated task gets.',
+    '还没有子代理。WorkHub 派发任务时,用子代理决定交给哪个模型、可以用哪些工具。',
+    '還沒有子代理。WorkHub 派發任務時,用子代理決定交給哪個模型、可以用哪些工具。',
+  ],
+  subagentsSaved: ['Subagent configurations saved.', '子代理配置已保存。', '子代理設定已儲存。'],
+  edit: ['Edit', '编辑', '編輯'],
+  subagentToolsOne: ['{count} tool', '{count} 个工具', '{count} 個工具'],
+  subagentToolsOther: ['{count} tools', '{count} 个工具', '{count} 個工具'],
+  subagentsNoTools: ['No tools', '不使用工具', '不使用工具'],
+  subagentsIsolated: ['Isolated Git workspace', '隔离 Git 工作区', '隔離 Git 工作區'],
+  subagentsIsolatedHelp: [
+    'Starts from a clean repository commit. Changes stay in a separate worktree and can be returned as an immutable patch.',
+    '从干净仓库的提交开始。修改保留在独立 worktree 中,可交付为不可变补丁。',
+    '從乾淨儲存庫的提交開始。修改保留在獨立 worktree 中,可交付為不可變修補檔。',
+  ],
+  subagentModelUnavailable: [
+    'This model is not available; choose another',
+    '该模型当前不可用,请重新选择',
+    '此模型目前無法使用,請重新選擇',
+  ],
+  subagentsAll: ['All subagents', '所有子代理', '所有子代理'],
+  subagentDescription: ['Description', '说明', '說明'],
+  subagentDescriptionHint: [
+    'What it is for. WorkHub reads this when it picks a subagent.',
+    '它适合做什么。WorkHub 选择子代理时会读这段说明。',
+    '它適合做什麼。WorkHub 選擇子代理時會讀這段說明。',
+  ],
+  subagentModel: ['Model', '模型', '模型'],
+  subagentChooseModel: ['Choose a model', '选择模型', '選擇模型'],
+  subagentTools: ['Tools', '工具', '工具'],
+  subagentToolsHelp: [
+    'Choose the tools this subagent may use. Unchecked tools remain unavailable even if the model requests them. Workspace permissions still apply. Restricted sessions use native models.',
+    '选择子代理可用的工具。即使模型主动调用,未选工具也不会执行;工作区权限仍然生效。受限会话使用原生模型。',
+    '選擇子代理可用的工具。即使模型主動呼叫,未選工具也不會執行;工作區權限仍然生效。受限工作階段使用原生模型。',
+  ],
+  subagentsRead: ['Local read tools', '本地读取工具', '本機讀取工具'],
+  subagentsWeb: ['Web research tools', '网页研究工具', '網頁研究工具'],
+  subagentsAllTools: ['All installed tools', '所有已安装工具', '所有已安裝工具'],
+  subagentsSelected: ['Selected tools: {count}', '已选择工具:{count}', '已選擇工具:{count}'],
+  subagentMissingTool: ['Unavailable tool: {name}', '工具不可用:{name}', '工具無法使用:{name}'],
+  subagentsEnabled: ['Available to WorkHub', '允许 WorkHub 使用', '允許 WorkHub 使用'],
+  subagentsEnabledHelp: [
+    'Allow WorkHub to delegate tasks to this subagent.',
+    '允许 WorkHub 将任务交给这个子代理。',
+    '允許 WorkHub 將任務交給這個子代理。',
+  ],
+  subagentId: ['ID: {id}', '标识:{id}', '識別碼:{id}'],
+  subagentSave: ['Save subagent', '保存子代理', '儲存子代理'],
+  subagentRemove: ['Remove subagent', '移除子代理', '移除子代理'],
+  subagentRemoveQuestion: [
+    'Remove this saved configuration? Existing tasks and sessions are retained.',
+    '移除此配置?已有任务和会话会保留。',
+    '移除此設定?已有任務和工作階段會保留。',
+  ],
+  subagentNameMissing: ['Name the subagent', '请为子代理命名', '請為子代理命名'],
+  subagentNameTooLong: [
+    'The name is longer than 128 characters',
+    '名称超过 128 个字符',
+    '名稱超過 128 個字元',
+  ],
+  subagentDescriptionTooLong: [
+    'The description is longer than 4000 characters',
+    '说明超过 4000 个字符',
+    '說明超過 4000 個字元',
+  ],
+  subagentsConflict: [
+    'Subagents were changed elsewhere and have been read again. Check, then save again.',
+    '子代理配置已被其他操作修改,已重新读取。请检查后再保存。',
+    '子代理設定已被其他操作修改,已重新讀取。請檢查後再儲存。',
+  ],
+  subagentsInvalid: [
+    'Check the name, model and tool selection.',
+    '请检查名称、模型及工具选择。',
+    '請檢查名稱、模型及工具選擇。',
+  ],
+  subagentSaveFailed: [
+    'Couldn’t save the subagent: {value}',
+    '保存子代理失败:{value}',
+    '儲存子代理失敗:{value}',
+  ],
+
+  // Settings: Archived (archived.js). Where the TUI (plugins/shared/
+  // locale-messages.ts) words the same thing, these copy it, with "task" for
+  // its "conversation".
+  archivedReadFailed: [
+    'Couldn’t read the archived tasks: {value}',
+    '读取已归档任务失败:{value}',
+    '讀取已封存任務失敗:{value}',
+  ],
+  noArchivedTasks: ['No archived tasks', '没有已归档任务', '沒有已封存任務'],
+  restoreTask: ['Restore', '恢复', '還原'],
+  deleteTask: ['Delete…', '删除…', '刪除…'],
+  deleteAction: ['Delete', '删除', '刪除'],
+  deleteTaskQuestion: ['Delete task?', '删除任务?', '刪除任務?'],
+  deleteTaskHelp: [
+    'This permanently removes this task from Maka. Workspace files and independent copies are kept.',
+    '将从 Maka 永久移除此任务。工作区文件和独立副本会保留。',
+    '將從 Maka 永久移除此任務。保留工作區檔案和獨立副本。',
+  ],
+  restoreFailed: [
+    'Couldn’t restore the task: {value}',
+    '恢复任务失败:{value}',
+    '還原任務失敗:{value}',
+  ],
+  deleteFailed: [
+    'Couldn’t delete the task: {value}',
+    '删除任务失败:{value}',
+    '刪除任務失敗:{value}',
+  ],
+
+  // Search (search.js, and its button in the sidebar). Where the TUI's
+  // "Search conversations" (plugins/shared/locale-messages.ts, msg_recall*)
+  // words the same thing, these copy it, with "task" for its "conversation".
+  search: ['Search', '搜索', '搜尋'],
+  searchTip: ['Search (⇧⌘F)', '搜索(⇧⌘F)', '搜尋(⇧⌘F)'],
+  searchPlaceholder: ['Search all tasks', '搜索所有任务', '搜尋所有任務'],
+  searchHint: [
+    'Find what was said in any task.',
+    '在所有任务中查找说过的内容。',
+    '在所有任務中尋找說過的內容。',
+  ],
+  searching: ['Searching…', '正在搜索…', '正在搜尋…'],
+  searchTooManyWords: [
+    'Use at most 8 words of up to 500 characters each.',
+    '最多 8 个词,每个词不超过 500 个字符。',
+    '最多 8 個詞,每個詞不超過 500 個字元。',
+  ],
+  searchPartial: [
+    'Some history could not be searched.',
+    '部分历史未能搜索。',
+    '部分歷史未能搜尋。',
+  ],
+  searchEmpty: ['Nothing matched.', '没有匹配的内容。', '沒有符合的內容。'],
+  searchFailed: ['Couldn’t search: {value}', '搜索失败:{value}', '搜尋失敗:{value}'],
+  searchClear: ['Clear', '清除', '清除'],
+
+  // WorkHub: setup and the conversation (workhub.js, main.js)
+  workhubChat: ['Chat', '对话', '對話'],
+  workhubTasks: ['Tasks', '任务', '任務'],
+  workhubReading: ['Reading WorkHub…', '正在读取 WorkHub…', '正在讀取 WorkHub…'],
+  workhubReadFailed: ['WorkHub could not be read', '无法读取 WorkHub', '無法讀取 WorkHub'],
+  workhubIntro: [
+    'WorkHub is one conversation that stays. Tell it what needs doing: it hands the work out as tasks, each in a session of its own, and brings the results back.',
+    'WorkHub 是一个一直保留的对话。告诉它要做什么,它会把工作分成任务,交给各自的会话去做,再把结果带回来。',
+    'WorkHub 是一個一直保留的對話。告訴它要做什麼,它會把工作分成任務,交給各自的會話去做,再把結果帶回來。',
+  ],
+  workhubFolderNote: [
+    'First choose the folder WorkHub itself works in. It cannot be changed later.',
+    '先选择 WorkHub 自己工作的文件夹,选定后不能再更改。',
+    '先選擇 WorkHub 自己工作的資料夾,選定後無法再更改。',
+  ],
+  workhubSettingUp: ['Setting up…', '正在设置…', '正在設定…'],
+  workhubSetupPending: [
+    'The last setup did not finish.',
+    '上次的设置还没完成。',
+    '上次的設定還沒完成。',
+  ],
+  workhubSetupFolder: ['Folder: {path}', '文件夹:{path}', '資料夾:{path}'],
+  workhubContinueSetup: ['Continue setup', '继续设置', '繼續設定'],
+  workhubChooseAnother: ['Choose another folder…', '选择其他文件夹…', '選擇其他資料夾…'],
+  workhubNotSetUp: ['WorkHub was not set up', 'WorkHub 未能设置', 'WorkHub 未能設定'],
+  workhubRejectedTitle: ['the title is not valid', '标题无效', '標題無效'],
+  workhubRejectedLabels: ['the labels are not valid', '标签无效', '標籤無效'],
+  workhubRejectedTools: ['its tools are not available', '所需的工具不可用', '所需的工具無法使用'],
+  workhubRejectedBusy: [
+    'the Host is busy; try again',
+    'Host 正忙,请稍后重试',
+    'Host 忙碌中,請稍後重試',
+  ],
+  workhubRejectedFolder: ['the folder is not available', '该文件夹不可用', '該資料夾無法使用'],
+  workhubRejectedSession: ['its session is not available', '会话不可用', '會話無法使用'],
+  workhubRejectedClosed: ['the Host is closing', 'Host 正在关闭', 'Host 正在關閉'],
+  workhubChatTitle: ['What should WorkHub take on?', '要 WorkHub 做什么?', '要 WorkHub 做什麼?'],
+  workhubChatHint: [
+    'Describe the work. WorkHub splits it into tasks, runs each in a session of its own and reports back here. Tasks shows how each one is going.',
+    '说说要完成的工作。WorkHub 会把它分成任务,交给各自的会话去做,再回到这里汇报。在“任务”里可以看每个任务的进展。',
+    '說說要完成的工作。WorkHub 會把它分成任務,交給各自的會話去做,再回到這裡匯報。在「任務」裡可以看每個任務的進展。',
+  ],
+  workhubNeedsSubagent: [
+    'WorkHub needs at least one available subagent to delegate tasks.',
+    'WorkHub 需要至少一个可用的子代理才能派发任务。',
+    'WorkHub 需要至少一個可用的子代理才能派發任務。',
+  ],
+  workhubSetUpSubagents: ['Set up subagents', '设置子代理', '設定子代理'],
+
+  // WorkHub: tasks (workhub.js)
+  workhubNoTasks: [
+    'No tasks yet. WorkHub creates them from the conversation.',
+    '还没有任务。WorkHub 会根据对话创建任务。',
+    '還沒有任務。WorkHub 會根據對話建立任務。',
+  ],
+  workhubUpdatesStopped: [
+    'The task list stopped updating.',
+    '任务列表已停止更新。',
+    '任務列表已停止更新。',
+  ],
+  workhubMoreTasks: ['More tasks', '更多任务', '更多任務'],
+  workhubFirstPage: ['Back to the first page', '回到第一页', '回到第一頁'],
+  workhubUntitled: ['Untitled task', '未命名任务', '未命名任務'],
+  workhubBlocked: [
+    'Waiting for earlier tasks ({count})',
+    '等待前置任务({count})',
+    '等待前置任務({count})',
+  ],
+  workhubCreating: ['Preparing: creating the session', '准备中:创建会话', '準備中:建立會話'],
+  workhubUploading: [
+    'Preparing: uploading file {index}/{total}',
+    '准备中:上传附件 {index}/{total}',
+    '準備中:上傳附件 {index}/{total}',
+  ],
+  workhubDelivering: ['Preparing: sending the message', '准备中:发送消息', '準備中:傳送訊息'],
+  workhubQueued: ['Queued', '排队中', '排隊中'],
+  workhubSubmitted: ['Submitted', '已提交', '已提交'],
+  workhubRunning: ['Running', '运行中', '執行中'],
+  workhubWaiting: ['Waiting for you', '等你处理', '等你處理'],
+  workhubPaused: ['Paused', '已暂停', '已暫停'],
+  workhubCompleted: ['Completed', '已完成', '已完成'],
+  workhubCancelled: ['Cancelled', '已取消', '已取消'],
+  workhubRejectedReason: ['Rejected: {reason}', '被拒绝:{reason}', '被拒絕:{reason}'],
+  workhubUnknown: ['Status unknown', '状态未知', '狀態未知'],
+  workhubUnavailable: ['Unavailable', '不可用', '無法使用'],
+  workhubChanged: ['The session changed', '会话已变化', '會話已變更'],
+  workhubAllTasks: ['All tasks', '全部任务', '全部任務'],
+  workhubOpenSession: ['Open task session', '打开任务会话', '開啟任務會話'],
+  workhubOpenFailed: [
+    'Opening the task session: {value}',
+    '打开任务会话失败:{value}',
+    '開啟任務會話失敗:{value}',
+  ],
+  workhubNoAnswer: [
+    'No reply yet. It shows here once the task has one.',
+    '还没有回复。任务回复后会显示在这里。',
+    '還沒有回覆。任務回覆後會顯示在這裡。',
+  ],
+  workhubReadMore: ['Continue reading', '继续读取', '繼續讀取'],
+  workhubReadMoreFailed: [
+    'Reading the reply: {value}',
+    '读取回复失败:{value}',
+    '讀取回覆失敗:{value}',
+  ],
+  workhubNoticeCompleted: [
+    'Task "{title}" completed',
+    '任务「{title}」已完成',
+    '任務「{title}」已完成',
+  ],
+  workhubNoticeCompletedUntitled: ['A task completed', '一个任务已完成', '一個任務已完成'],
+  workhubNoticeFailed: ['Task "{title}" failed', '任务「{title}」失败了', '任務「{title}」失敗了'],
+  workhubNoticeFailedUntitled: ['A task failed', '一个任务失败了', '一個任務失敗了'],
+  workhubNoticeCancelled: [
+    'Task "{title}" was cancelled',
+    '任务「{title}」已取消',
+    '任務「{title}」已取消',
+  ],
+  workhubNoticeCancelledUntitled: ['A task was cancelled', '一个任务已取消', '一個任務已取消'],
+  workhubNoticeRejected: [
+    'Task "{title}" was rejected',
+    '任务「{title}」被拒绝',
+    '任務「{title}」被拒絕',
+  ],
+  workhubNoticeRejectedUntitled: ['A task was rejected', '一个任务被拒绝', '一個任務被拒絕'],
+  workhubNoticeEnded: ['Task "{title}" ended', '任务「{title}」已结束', '任務「{title}」已結束'],
+  workhubNoticeEndedUntitled: ['A task ended', '一个任务已结束', '一個任務已結束'],
+  workhubNoticeGraph: ['A task graph has new results', '任务图有新结果', '任務圖有新結果'],
+  workhubNoticeView: ['View', '查看', '查看'],
+};
diff --git a/products/maka/desktop-shell/app/search.js b/products/maka/desktop-shell/app/search.js
new file mode 100644
index 0000000000..8741b4f273
--- /dev/null
+++ b/products/maka/desktop-shell/app/search.js
@@ -0,0 +1,712 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// The Search page: what was said in every task, as the Host's `agent.recall`
+// finds it. It follows the native client's Search page (maka-gpui
+// crates/search/src/page.rs) and the TUI's "Search conversations"
+// (plugins/agent/recall/index.ts): the field is searched as the person types,
+// after a pause; the answer lists the tasks whose titles match, then the
+// passages that match, grouped by task; a passage opens its task at that
+// message (the transcript's anchor). The page's state lives as long as the
+// window, so going Back to it shows the same words and results.
+//
+// The results are a `list`: its scroll position, like any element's, is
+// kept only while it is drawn, but a list keeps the item at its top when
+// items are added above it, and that is how the page comes back where the
+// reader left it (Search.showing).
+
+import { div, list } from 'gpui-kit';
+import { v_flex, h_flex, InputState, TextView, Scrollbar } from 'gpui-base';
+import { query, log } from './host.js';
+import {
+  palette,
+  typeset,
+  text,
+  icon,
+  iconButton,
+  controlButton,
+  textField,
+  pill,
+  compactAge,
+  focusInput,
+  escapeHtml,
+} from './theme.js';
+import { t, onLanguageChange } from './locale.js';
+
+/** How long the page waits after the field last changed before it searches. */
+const PAUSE_MS = 250;
+/** Results asked for, the Host's most: title matches first, passages fill the rest. */
+const LIMIT = 25;
+/** The most words one search takes, and the most characters in a word (recall/types.rs). */
+const MAX_TERMS = 8;
+const MAX_TERM_CHARS = 500;
+/** How much of a passage a row shows, in characters, and how much of that comes before the first match. */
+const EXCERPT_CHARS = 220;
+const BEFORE_MATCH = 60;
+/** How far a cut moves to fall between two words, in characters. */
+const WORD_SLACK = 16;
+/** The fill behind a match: the native client's find match (maka-gpui crates/shared/src/theme.rs). */
+const MARK = { light: '#FFCE2F59', dark: '#FFCE2F38' };
+
+/** The words of `value` as the Host is asked for them: split on whitespace, as the TUI splits them. */
+function termsOf(value) {
+  return value.split(/\s+/u).filter(Boolean);
+}
+
+function sameTerms(a, b) {
+  return a.length === b.length && a.every((term, index) => term === b[index]);
+}
+
+function titleKey(session) {
+  return `title/${session.id}`;
+}
+
+/** A passage by its task and its row: one message can be found in several tasks' copies of it. */
+function passageKey(passage) {
+  return `passage/${passage.session.id}/${passage.anchorSequence}`;
+}
+
+export class Search {
+  /**
+   * Creates retained input state and a focus handle, so it belongs in the
+   * view's `init`, whose context it keeps for the pause and the Host calls.
+   * @param {{ changed: () => void, openTask: (session: object) => void,
+   *   openPassage: (session: object, anchor: number) => void, back: () => void }} hooks
+   *   `openTask` shows a task at its newest message, `openPassage` at the
+   *   passage's row, and `back` goes back from the page
+   */
+  constructor(hooks, cx) {
+    this.hooks = hooks;
+    this.cx = cx;
+    /** Whether the page shows in the plate. */
+    this.open = false;
+    this.field = InputState.new({ placeholder: t('searchPlaceholder') });
+    onLanguageChange(() => this.field.set_placeholder(t('searchPlaceholder')));
+    this.field.on('change', () => this.changed());
+    // Enter; Escape, Up and Down come through the row around the field (key).
+    this.field.on('submit', () => this.submit());
+    this.field.on('focus', () => {
+      this.focused = true;
+    });
+    this.field.on('blur', () => {
+      this.focused = false;
+    });
+    /** Whether the field has the keyboard, as its focus events say. */
+    this.focused = false;
+    /** On the row around the field: what the keyboard is moved from to reach it (focusInput). */
+    this.fieldFocus = cx.focus_handle();
+    /** Counts the attempts to focus the field; only the latest goes on. */
+    this.focusing = 0;
+    /** The field's text, as of its last change. */
+    this.words = '';
+    /** Bumped by every search and by every change that drops one: an answer to an earlier one is dropped. */
+    this.serial = 0;
+    /** The search waiting out the pause or running, `{ serial, words, terms }`, or null. */
+    this.pending = null;
+    /**
+     * The last answer, `{ serial, words, terms, titles, groups, complete,
+     * results }`: the passages grouped by task in the Host's order, and the
+     * keys of the results in the page's order. Null before one and for a
+     * blank field.
+     */
+    this.answer = null;
+    /** Why the last search failed, `{ words, message }`, or null. */
+    this.failure = null;
+    /** Whether the field holds more words, or a longer word, than the Host takes. */
+    this.invalid = false;
+    /** The key of the result Up and Down moved to, or null. */
+    this.cursor = null;
+    /** The key of the item at the top of the results while they last showed, or null. */
+    this.top = null;
+    /** Set when the page shows again: the results are drawn from `top` first (showing). */
+    this.restoring = false;
+    /** The items the results list last showed, `{ start, end, count }`, or null. */
+    this.visible = null;
+    /** Host calls made, and answers dropped for a later search. */
+    this.stats = { calls: 0, dropped: 0 };
+  }
+
+  show() {
+    this.open = true;
+    this.restoring = this.top !== null && this.shown() !== null;
+  }
+
+  hide() {
+    this.open = false;
+  }
+
+  /**
+   * Gives the field the keyboard with its text selected, so typing replaces
+   * it: the page's button and ⇧⌘F.
+   */
+  focusField() {
+    const attempt = ++this.focusing;
+    this.cx.spawn(async (cx) => {
+      const current = await focusInput(
+        this.fieldFocus,
+        () => this.focused,
+        () => this.open && attempt === this.focusing,
+        cx,
+      );
+      if (!current) return;
+      this.field.set_selected_range({ start: 0, end: this.field.value().length });
+      log(
+        `search: the field ${this.focused ? 'has the keyboard' : 'was given the keyboard (no focus event yet)'}`,
+      );
+    });
+  }
+
+  /**
+   * The field changed. A blank field clears the answer; the words of the
+   * answer shown need no new search; other words are searched after the pause.
+   */
+  changed() {
+    this.words = this.field.value();
+    const terms = termsOf(this.words);
+    this.cursor = null;
+    if (terms.length > 0 && (this.answer === null || !sameTerms(terms, this.answer.terms))) {
+      this.search(this.words, PAUSE_MS);
+      return;
+    }
+    this.serial += 1;
+    this.pending = null;
+    this.failure = null;
+    this.invalid = false;
+    if (terms.length === 0) {
+      this.answer = null;
+      this.top = null;
+    }
+    this.hooks.changed();
+  }
+
+  /** Enter: searches now when the words are not the answer's, else opens the result under the cursor or the first. */
+  submit() {
+    this.words = this.field.value();
+    const terms = termsOf(this.words);
+    if (terms.length === 0) return;
+    const answer = this.answer;
+    if (answer === null || this.failure !== null || !sameTerms(terms, answer.terms)) {
+      this.search(this.words, 0);
+      return;
+    }
+    const key = this.cursor ?? answer.results[0] ?? null;
+    if (key !== null) this.openResult(key);
+  }
+
+  /** Retry: searches the field's words again now. */
+  retry() {
+    this.search(this.field.value(), 0);
+  }
+
+  /** The clear button: empties the field and gives it the keyboard back. */
+  clear() {
+    this.field.set_value('');
+    this.changed();
+    this.focusField();
+  }
+
+  /** Escape: clears the field, or with nothing in it goes back. */
+  escape() {
+    if (this.field.value() !== '') {
+      this.field.set_value('');
+      this.changed();
+      return;
+    }
+    this.hooks.back();
+  }
+
+  /** Keys the field passes on: Escape, and Up and Down, which a one-line field leaves alone. */
+  key(event) {
+    if (event.keystroke === 'escape') this.escape();
+    else if (event.keystroke === 'up') this.move(-1);
+    else if (event.keystroke === 'down') this.move(1);
+  }
+
+  /** Moves the cursor to the next result (`step` 1) or the one before (-1), from the field. */
+  move(step) {
+    const keys = this.shown()?.results ?? [];
+    if (keys.length === 0) return;
+    const at = keys.indexOf(this.cursor);
+    const index =
+      at < 0 ? (step > 0 ? 0 : keys.length - 1) : Math.max(0, Math.min(keys.length - 1, at + step));
+    this.cursor = keys[index];
+    this.hooks.changed();
+  }
+
+  /**
+   * Searches `words` after `pause` ms, or now. Words the Host would refuse
+   * are not sent; the line under the field says why. The Host has no
+   * cancel, so each search is numbered and an answer to any but the latest
+   * is dropped (accept).
+   */
+  search(words, pause) {
+    const terms = termsOf(words);
+    const serial = ++this.serial;
+    this.failure = null;
+    this.cursor = null;
+    this.invalid =
+      terms.length > MAX_TERMS || terms.some((term) => [...term].length > MAX_TERM_CHARS);
+    this.pending = this.invalid || terms.length === 0 ? null : { serial, words, terms };
+    this.hooks.changed();
+    if (this.pending === null) return;
+    this.cx.spawn(async (cx) => {
+      if (pause > 0) await cx.sleep(pause);
+      if (serial !== this.serial) return;
+      log(`search #${serial}: asking for ${terms.length} word(s)`);
+      let found;
+      try {
+        found = await this.recall(terms);
+      } catch (error) {
+        this.fail(serial, words, error);
+        return;
+      }
+      this.accept(serial, words, terms, found);
+    });
+  }
+
+  /** One `agent.recall` across every task, archived ones too. */
+  recall(terms) {
+    this.stats.calls += 1;
+    return query('agent', 'recall', {
+      terms,
+      question: null,
+      limit: LIMIT,
+      session_id: null,
+      since: null,
+      until: null,
+    });
+  }
+
+  /** Takes the Host's answer to search `serial`, if it is the latest. Resolves with whether it was. */
+  accept(serial, words, terms, found) {
+    if (serial !== this.serial) {
+      this.stats.dropped += 1;
+      log(`search #${serial}: answer dropped, #${this.serial} is the latest`);
+      return false;
+    }
+    const groups = groupsOf(found.passages);
+    this.answer = {
+      serial,
+      words,
+      terms,
+      titles: found.titles,
+      groups,
+      complete: found.complete,
+      results: [
+        ...found.titles.map(titleKey),
+        ...groups.flatMap((group) => group.passages.map(passageKey)),
+      ],
+    };
+    this.pending = null;
+    this.failure = null;
+    this.cursor = null;
+    this.top = null;
+    this.restoring = false;
+    log(
+      `search #${serial}: ${found.titles.length} title(s), ${found.passages.length} passage(s)` +
+        `${found.complete ? '' : ', not complete'}`,
+    );
+    // The gaps are sentences for a model: logged, not shown.
+    for (const gap of found.gaps ?? []) log(`search #${serial}: gap: ${gap}`);
+    this.hooks.changed();
+    return true;
+  }
+
+  fail(serial, words, error) {
+    if (serial !== this.serial) {
+      this.stats.dropped += 1;
+      log(`search #${serial}: failure dropped, #${this.serial} is the latest`);
+      return;
+    }
+    log(`search #${serial} failed: ${error.message}`);
+    this.pending = null;
+    this.failure = { words, message: error.message };
+    this.hooks.changed();
+  }
+
+  /**
+   * The answer the page shows: the last one while it is for the field's
+   * words, and while a search for other words waits or runs (dimmed).
+   */
+  shown() {
+    const answer = this.answer;
+    if (answer === null) return null;
+    if (this.pending !== null) return answer;
+    if (this.invalid || this.failure !== null) return null;
+    return sameTerms(termsOf(this.words), answer.terms) ? answer : null;
+  }
+
+  /** The line under the field, or null: the hint, what the page is doing, or what is wrong with the words. */
+  status() {
+    if (termsOf(this.words).length === 0) return t('searchHint');
+    if (this.invalid) return t('searchTooManyWords');
+    if (this.pending !== null) return t('searching');
+    if (this.shown()?.complete === false) return t('searchPartial');
+    return null;
+  }
+
+  /** Opens result `key` (a list item's key): a title at its task's newest message, a passage at its own. */
+  openResult(key) {
+    const answer = this.shown();
+    if (answer === null) return;
+    const session = answer.titles.find((entry) => titleKey(entry) === key);
+    if (session !== undefined) {
+      log(`search: opening ${session.id}`);
+      this.hooks.openTask(session);
+      return;
+    }
+    for (const group of answer.groups) {
+      const passage = group.passages.find((entry) => passageKey(entry) === key);
+      if (passage === undefined) continue;
+      log(`search: opening ${passage.session.id} at row ${passage.anchorSequence}`);
+      this.hooks.openPassage(passage.session, passage.anchorSequence);
+      return;
+    }
+  }
+
+  /**
+   * The results list showed `range` of its `count` items, `items` being
+   * the whole list from index `from` on. Back on the page, the list is new
+   * and would open at its first item, so for one frame it is drawn from the
+   * item that was at its top (`from`); drawn whole after that, the list
+   * keeps that item at its top. Otherwise the item at the top is noted.
+   */
+  showing(range, count, items, from) {
+    this.visible = { start: range.start + from, end: range.end + from, count: count + from };
+    if (this.restoring) {
+      this.restoring = false;
+      if (from > 0) this.hooks.changed();
+      return;
+    }
+    this.top = items[range.start]?.key ?? null;
+  }
+}
+
+/** Passages by task, the tasks in the order the Host first names them. */
+function groupsOf(passages) {
+  const byTask = new Map();
+  for (const passage of passages) {
+    let group = byTask.get(passage.session.id);
+    if (group === undefined) {
+      group = { session: passage.session, passages: [] };
+      byTask.set(passage.session.id, group);
+    }
+    group.passages.push(passage);
+  }
+  return [...byTask.values()];
+}
+
+/**
+ * One RegExp that finds any of `terms`, the longer first, as the Host matches
+ * them: literally, in any case, composed (NFC) as the Host composes the terms
+ * and the excerpts it returns.
+ */
+function matcher(terms) {
+  const escaped = terms
+    .map((term) => term.normalize('NFC'))
+    .sort((a, b) => b.length - a.length)
+    .map((term) => term.replace(/[\\^$.*+?()[\]{}|/]/g, '\\$&'));
+  return new RegExp(escaped.join('|'), 'giu');
+}
+
+/** Whether the UTF-16 unit at `index` is the second half of a surrogate pair. */
+function lowSurrogate(value, index) {
+  const unit = value.charCodeAt(index);
+  return unit >= 0xdc00 && unit <= 0xdfff;
+}
+
+/**
+ * A passage's text as its row shows it, as HTML: runs of whitespace made one
+ * space; about EXCERPT_CHARS of it, from about BEFORE_MATCH before the first
+ * match, cut between words where a space is near, with "…" where it was
+ * cut; every match of `terms` marked on `fill`. All of the text is
+ * escaped, so none of it is read as markup.
+ */
+export function excerptHtml(excerpt, terms, fill) {
+  const pattern = matcher(terms);
+  const whole = excerpt.replace(/\s+/gu, ' ').trim();
+  pattern.lastIndex = 0;
+  const first = pattern.exec(whole);
+  const at = first === null ? 0 : first.index;
+  const matched = first === null ? 0 : first[0].length;
+  let start = Math.max(0, at - BEFORE_MATCH);
+  let end = Math.min(whole.length, start + EXCERPT_CHARS);
+  start = Math.max(0, Math.min(start, end - EXCERPT_CHARS));
+  if (start > 0 && whole[start - 1] !== ' ') {
+    const space = whole.indexOf(' ', start);
+    if (space >= 0 && space < Math.min(at, start + WORD_SLACK)) start = space + 1;
+  }
+  if (end < whole.length && whole[end] !== ' ') {
+    const space = whole.lastIndexOf(' ', end);
+    if (space > Math.max(at + matched, end - WORD_SLACK)) end = space;
+  }
+  if (start > 0 && lowSurrogate(whole, start)) start -= 1;
+  if (end < whole.length && lowSurrogate(whole, end)) end += 1;
+  const shown = whole.slice(start, end);
+  let html = '';
+  let last = 0;
+  // matchAll starts where the exec above left the pattern's lastIndex.
+  pattern.lastIndex = 0;
+  for (const match of shown.matchAll(pattern)) {
+    html += `${escapeHtml(shown.slice(last, match.index))}<mark color="${fill}">${escapeHtml(match[0])}</mark>`;
+    last = match.index + match[0].length;
+  }
+  html += escapeHtml(shown.slice(last));
+  return `${start > 0 ? '…' : ''}${html}${end < whole.length ? '…' : ''}`;
+}
+
+/** A block in the page's column, which the transcript's reading column matches. */
+function column(child) {
+  return h_flex()
+    .flex_none()
+    .w_full()
+    .px(24)
+    .justify_center()
+    .child(v_flex().w_full().max_w(720).child(child));
+}
+
+/**
+ * The Search page under the plate's header: the field and the line under
+ * it, which stay in place, then what the answer found, which scrolls. The
+ * results are dimmed while a search for other words waits or runs.
+ * @param {Search} search
+ * @param {object[]} sessions the catalog's sessions: a task's title is the
+ *   catalog's when it lists the task, which may have been renamed since
+ */
+export function searchPage(search, sessions, cx) {
+  const m = palette(cx);
+  const answer = search.shown();
+  const stale = search.pending !== null;
+  const failure = search.pending === null ? search.failure : null;
+  const status = search.status();
+  const found = answer !== null && answer.results.length > 0;
+  return v_flex()
+    .id('search-page')
+    .flex_1()
+    .min_h_0()
+    .child(column(searchField(search, cx)).pt(8))
+    .child(
+      column(
+        typeset(div(), 'small')
+          .min_h(20)
+          .px(12)
+          .text_color(m.inkMuted)
+          .when(status !== null, (el) => el.child(status)),
+      ).pt(8),
+    )
+    .when(failure !== null, (el) =>
+      el.child(
+        column(
+          h_flex()
+            .px(12)
+            .gap(8)
+            .items_center()
+            .child(
+              text(t('searchFailed', { value: failure.message }), 'small', m.destructive)
+                .flex_1()
+                .min_w_0(),
+            )
+            .child(
+              controlButton('search-retry', t('retry'), 'quiet', false, cx)
+                .h(28)
+                .on_click(() => search.retry()),
+            ),
+        ).pt(16),
+      ),
+    )
+    .when(answer !== null && !found, (el) =>
+      el.child(
+        column(text(t('searchEmpty'), 'body', m.inkMuted).px(12))
+          .pt(24)
+          .when(stale, (el) => el.opacity(0.5)),
+      ),
+    )
+    .when(found, (el) => el.child(resultsList(search, answer, sessions, stale, cx)));
+}
+
+/**
+ * The field, 36 high: the app's text field with the search glyph at its
+ * start and, while it holds text, the clear button at its end, both laid
+ * over it so the field keeps its own focus ring. The row around it takes
+ * the keys the field passes on (Search.key).
+ */
+function searchField(search, cx) {
+  const m = palette(cx);
+  const typed = search.words !== '';
+  return div()
+    .relative()
+    .w_full()
+    .h(36)
+    .track_focus(search.fieldFocus)
+    .on_key_down((event) => search.key(event))
+    .child(
+      textField(search.field, cx)
+        .h(36)
+        .pl(36)
+        .pr(typed ? 36 : 12),
+    )
+    .child(icon('search', 16, m.inkMuted).absolute().left(12).top(10))
+    .when(typed, (el) =>
+      el.child(
+        iconButton('search-clear', 'close', cx, 24, 16)
+          .absolute()
+          .right(6)
+          .top(6)
+          .accessibility_label(t('searchClear'))
+          .on_click(() => search.clear()),
+      ),
+    );
+}
+
+/**
+ * The list items an answer shows: "Tasks" over the title matches, then a
+ * heading for each task with passages and the passages under it, and the
+ * space at the end.
+ */
+function resultItems(answer, byId, fill) {
+  const items = [];
+  const listed = (session) => byId.get(session.id) ?? session;
+  if (answer.titles.length > 0) {
+    items.push({ key: 'tasks', kind: 'label' });
+    for (const session of answer.titles) {
+      items.push({ key: titleKey(session), kind: 'title', session: listed(session) });
+    }
+  }
+  for (const group of answer.groups) {
+    items.push({ key: `task/${group.session.id}`, kind: 'task', session: listed(group.session) });
+    for (const passage of group.passages) {
+      items.push({
+        key: passageKey(passage),
+        kind: 'passage',
+        html: excerptHtml(passage.excerpt, answer.terms, fill),
+      });
+    }
+  }
+  items.push({ key: 'end', kind: 'end' });
+  return items;
+}
+
+/**
+ * What the answer found, in a measured list in the page's column with its
+ * scrollbar. A new answer is a new list, which opens at its top; Back on
+ * the page, it opens where the reader left it (Search.showing). A click on
+ * a title or a passage opens it.
+ */
+function resultsList(search, answer, sessions, stale, cx) {
+  const byId = new Map(sessions.map((session) => [session.id, session]));
+  const all = resultItems(answer, byId, cx.theme().is_dark ? MARK.dark : MARK.light);
+  const from = search.restoring
+    ? Math.max(
+        0,
+        all.findIndex((item) => item.key === search.top),
+      )
+    : 0;
+  const items = from > 0 ? all.slice(from) : all;
+  const id = `search-results-${answer.serial}`;
+  return v_flex()
+    .relative()
+    .flex_1()
+    .min_h_0()
+    .child(
+      list(
+        id,
+        items.length,
+        (index) => items[index].key,
+        (index, cx) => resultItem(items[index], search.cursor, answer.serial, stale, cx),
+      )
+        .on_visible_range_change((range, count) => search.showing(range, count, items, from))
+        .on_item_click((key) => search.openResult(key)),
+    )
+    .child(Scrollbar.vertical(id).absolute().inset_0());
+}
+
+/** One item of the results, in the page's column; called from layout for each visible item. */
+function resultItem(item, cursor, serial, stale, cx) {
+  return column(resultBody(item, item.key === cursor, serial, cx)).when(
+    stale && item.kind !== 'end',
+    (el) => el.opacity(0.5),
+  );
+}
+
+function resultBody(item, selected, serial, cx) {
+  const m = palette(cx);
+  switch (item.kind) {
+    case 'label':
+      // The sidebar's group heading.
+      return text(t('workhubTasks'), 'smallLabel', m.inkMuted).px(12).pt(24).pb(4);
+    case 'title':
+      return resultRow(h_flex(), selected, cx)
+        .h(36)
+        .gap(8)
+        .items_center()
+        .child(icon('compose', 16, m.inkMuted))
+        .child(typeset(div(), 'body').min_w_0().truncate().child(item.session.title))
+        .children(facets(item.session, cx));
+    case 'task':
+      return h_flex()
+        .w_full()
+        .px(12)
+        .pt(24)
+        .pb(4)
+        .gap(8)
+        .items_center()
+        .child(text(item.session.title, 'strong', m.ink).min_w_0().truncate())
+        .children(facets(item.session, cx));
+    case 'passage':
+      // The excerpt is drawn, not selected: a press anywhere on the row opens it.
+      return resultRow(v_flex(), selected, cx)
+        .py(8)
+        .child(
+          typeset(div(), 'body')
+            .w_full()
+            .text_color(m.ink)
+            .child(
+              TextView.html(`search-excerpt-${serial}-${item.key}`, item.html)
+                .selectable(false)
+                .text_style({ paragraph_gap: 0, foreground: m.ink }),
+            ),
+        );
+    default:
+      return div().h(48);
+  }
+}
+
+/** A result's row: the hover fill, or the selected fill while the cursor is on it. */
+function resultRow(el, selected, cx) {
+  const m = palette(cx);
+  return el
+    .w_full()
+    .px(12)
+    .rounded(10)
+    .text_color(m.ink)
+    .when(selected, (el) => el.bg(m.selected))
+    .when(!selected, (el) => el.hover((style) => style.bg(m.hover)));
+}
+
+/** What a task's title is followed by: its folder, Archived when it is, and its age at the end. */
+function facets(session, cx) {
+  const m = palette(cx);
+  const path = session.workspace?.path ?? '';
+  const folder = path.split('/').filter(Boolean).pop() ?? t('noFolder');
+  const parts = [text(folder, 'small', m.inkMuted).flex_none().max_w(200).truncate()];
+  if (session.archived === true) parts.push(pill(t('settingsArchived'), cx));
+  parts.push(div().flex_1());
+  parts.push(text(compactAge(session.updatedAt), 'small', m.inkMuted).flex_none());
+  return parts;
+}
diff --git a/products/maka/desktop-shell/app/sessions.js b/products/maka/desktop-shell/app/sessions.js
new file mode 100644
index 0000000000..0ceb325fba
--- /dev/null
+++ b/products/maka/desktop-shell/app/sessions.js
@@ -0,0 +1,920 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// The task list: a live `agent.catalog` subscription and the sidebar that
+// shows it, grouped by day or by folder as the native client does. A task's
+// "…" menu (or a right-click on its row) renames it, copies its id or
+// archives it, as the native client's task menu does
+// (maka-gpui crates/session/src/sidebar/row_menu.rs).
+
+import { div, svg } from 'gpui-kit';
+import { v_flex, h_flex, Button, InputState, Popover } from 'gpui-base';
+import { query, operation, subscribe, next, close, log } from './host.js';
+import {
+  palette,
+  typeset,
+  text,
+  icon,
+  tippedIcon,
+  dot,
+  ghostButton,
+  iconButton,
+  segmentedControl,
+  compactAge,
+  textField,
+  focusInput,
+  menuSurface,
+  menuRow,
+  menuSeparator,
+} from './theme.js';
+import { t, onLanguageChange } from './locale.js';
+
+const LIST = { filter: 'all', position: { kind: 'start' } };
+const RETRY_MS = 2000;
+/** Pages read after the subscribed first one; the Host pages at most 32 sessions in id order. */
+const MAX_PAGES = 32;
+
+export class SessionCatalog {
+  /**
+   * @param {{ changed: () => void, report: (message: string) => void }} hooks
+   */
+  constructor(hooks) {
+    this.hooks = hooks;
+    /** Every listed session, newest first. Replaced, never edited. */
+    this.sessions = [];
+    /** The subscribed first page, and the pages after it as last read. */
+    this.first = [];
+    this.rest = [];
+    this.reading = 0;
+    this.needsYou = 0;
+    /** "connecting", "live" or "offline". */
+    this.state = 'connecting';
+    this.revision = null;
+    this.handle = null;
+    this.restarting = false;
+    /** Set by Retry: the offline pause ends at once. */
+    this.wakeUp = false;
+  }
+
+  /**
+   * Holds one catalog subscription with a single `next()` loop. When it fails
+   * or ends, one `agent.list` query keeps the sidebar current and the
+   * subscription is opened again after a pause.
+   */
+  async run(cx) {
+    for (;;) {
+      try {
+        this.handle = await subscribe('agent', 'catalog', LIST);
+        log(`catalog: subscribed as ${this.handle}`);
+        for (;;) {
+          const page = await next(this.handle);
+          if (page === null) break;
+          this.apply(page, 'live');
+        }
+        if (!this.restarting) log('catalog: the Host ended the subscription');
+      } catch (error) {
+        // A restart closes the handle under the loop; its next() then fails.
+        if (!this.restarting) this.hooks.report(t('sessionListFailed', { value: error.message }));
+      } finally {
+        if (this.handle !== null) await close(this.handle).catch(() => {});
+        this.handle = null;
+      }
+      if (this.restarting) {
+        this.restarting = false;
+        continue;
+      }
+      this.state = 'offline';
+      this.hooks.changed();
+      try {
+        this.apply(await query('agent', 'list', LIST), 'offline');
+      } catch (error) {
+        this.hooks.report(t('sessionListFailed', { value: error.message }));
+      }
+      for (let waited = 0; waited < RETRY_MS && !this.wakeUp; waited += 100) await cx.sleep(100);
+      this.wakeUp = false;
+    }
+  }
+
+  /** Ends the pause before the next attempt to reach the Host. */
+  retryNow() {
+    this.wakeUp = true;
+  }
+
+  /**
+   * Reopens the subscription at the start. After a change the Host reads the
+   * catalog again from the first id it last sent, and ids are random, so a
+   * new session can sort before that id and fall outside the window; the TUI
+   * likewise reopens its catalog watch after creating a session.
+   */
+  restart() {
+    if (this.handle === null || this.restarting) return;
+    this.restarting = true;
+    close(this.handle).catch(() => {});
+  }
+
+  apply(page, state) {
+    if (state === 'live' && page.before !== null) {
+      log('catalog: sessions sort before the window; reopening at the start');
+      this.restart();
+      return;
+    }
+    // The catalog answers after every commit; an unchanged revision is the same page.
+    if (page.revision === this.revision && state === this.state) return;
+    this.revision = page.revision;
+    this.first = page.sessions;
+    this.needsYou = page.needsYouCount;
+    this.state = state;
+    if (page.after === null) {
+      this.reading += 1;
+      this.rest = [];
+    } else {
+      this.readRest(page);
+    }
+    this.publish();
+  }
+
+  /**
+   * Reads the pages after the first at its revision. Sessions are paged in
+   * id order, so the newest can be on any page. A conflict means the catalog
+   * changed meanwhile; the page that change brings reads them again.
+   */
+  async readRest(page) {
+    const reading = ++this.reading;
+    const rest = [];
+    let after = page.after;
+    try {
+      for (let count = 0; after !== null && count < MAX_PAGES; count += 1) {
+        const next = await query('agent', 'list', {
+          filter: LIST.filter,
+          position: { kind: 'after', revision: page.revision, cursor: after },
+        });
+        if (reading !== this.reading) return;
+        rest.push(...next.sessions);
+        after = next.after;
+      }
+    } catch (error) {
+      if (reading === this.reading) log(`catalog: later pages: ${error.message}`);
+      return;
+    }
+    this.rest = rest;
+    this.publish();
+  }
+
+  publish() {
+    const byId = new Map(this.rest.map((session) => [session.id, session]));
+    for (const session of this.first) byId.set(session.id, session);
+    this.sessions = [...byId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
+    log(`catalog: ${this.sessions.length} sessions, ${this.needsYou} need you`);
+    this.hooks.changed();
+  }
+}
+
+/** A session is waiting on the user (a question or an approval). */
+export function needsYou(session) {
+  return session.activity === 'waiting';
+}
+
+/** The longest title the Host takes, in characters (`agent.metadata`). */
+const MAX_TITLE = 128;
+
+/**
+ * Renames `session` (`agent.metadata`) with its labels kept and nothing else
+ * changed. A revision conflict reads the session again and tries once more,
+ * as the composer's permission change does. Resolves with the session as
+ * the Host committed it.
+ */
+async function renameSession(session, title) {
+  let current = session;
+  for (let attempt = 0; ; attempt += 1) {
+    const result = await operation('agent', 'metadata', {
+      sessionId: current.id,
+      expectedRevision: current.revision,
+      title,
+      labels: [...current.labels],
+    });
+    if (result.kind === 'committed') return result.session;
+    if (result.kind === 'rejected') throw new Error(t('hostRefused', { value: result.reason }));
+    if (attempt > 0) throw new Error(t('taskChangedMeanwhile'));
+    current = (await query('agent', 'observe', { sessionId: current.id })).session;
+  }
+}
+
+/**
+ * Archives, restores or removes `session` (`agent.changeSession`, `action`
+ * 'archive', 'restore' or 'remove'), with the same one retry on a revision
+ * conflict. Resolves with the Host's answer, `changed` or `removed`. A
+ * refusal throws; a busy session throws a whole sentence (`error.whole`)
+ * that is shown as it is.
+ */
+export async function changeSession(session, action) {
+  let current = session;
+  for (let attempt = 0; ; attempt += 1) {
+    const result = await operation('agent', 'changeSession', {
+      sessionId: current.id,
+      expectedRevision: current.revision,
+      action,
+    });
+    if (result.kind === 'changed' || result.kind === 'removed') return result;
+    if (result.kind === 'rejected') {
+      if (result.reason === 'busy') throw whole(t('sessionChangeBusy'));
+      throw new Error(t('hostRefused', { value: result.reason }));
+    }
+    if (result.kind !== 'revision_conflict') {
+      throw new Error(t('unexpectedAnswer', { value: result.kind }));
+    }
+    if (attempt > 0) throw new Error(t('taskChangedMeanwhile'));
+    current = (await query('agent', 'observe', { sessionId: current.id })).session;
+  }
+}
+
+function whole(message) {
+  const error = new Error(message);
+  error.whole = true;
+  return error;
+}
+
+/**
... 118247 lines suppressed ...


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org