Files
coop/docs/GPUI_NATIVE_KIT_MIGRATION_PLAN.md
T
2026-11-09 21:20:14 +07:00

35 KiB

Migrate coop to gpui-native-kit

Context

coop (workspace root /Users/reya/Documents/Git/coop) builds every screen on gpui-kit 0.8.1 (a facade over gpui-component/gpui-base) plus native-theme 0.7. The target is gpui-native-kit at /Users/reya/Documents/Git/gpui-native-kit (git ssh://git@git.reya.info/reya/gpui-native-kit.git), using its theme, icons, core components, data views, editor markdown, and layout dock. The app must end with one design system: delete local duplicates (ui::Avatar, settings_row, settings_block, title_bar_drag_handlers, the old dock skin/tab bar/window controls), keep app-specific product views (MessageRow, ReactionBar, Nav, etc.) rebased on kit primitives and tokens. gpui-kit remains only as the speech compatibility island because gpui-native-kit has no speech module.

Scope decisions taken: only design-system duplicates are deleted; product-specific views (MessageRow, ReactionBar, Nav, etc.) stay and are rebased on kit primitives/tokens. Speech/dictation stays on the old gpui-kit because the new kit has no speech module; that is the only remaining old-kit dependency.

The new kit uses gpui from zed git; coop uses the crates.io gpui-pre 0.3.10 package. Both must resolve to one gpui crate. The kit's workspace manifest is therefore the first edit.

Step 0 — dependency wiring (prerequisite, outside coop)

  1. Create the working branches before any edit:
    • In /Users/reya/Documents/Git/coop: git switch -c feat/gpui-native-kit-migration.
    • In /Users/reya/Documents/Git/gpui-native-kit: git switch -c feat/gpui-pre.
  2. In /Users/reya/Documents/Git/gpui-native-kit/Cargo.toml, change [workspace.dependencies] to the same gpui family coop already uses:
gpui = { package = "gpui-pre", version = "0.3.10" }
gpui_platform = { package = "gpui-pre-platform", version = "0.3.10", features = ["font-kit"] }
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.10" }
  1. In /Users/reya/Documents/Git/gpui-native-kit/src/lib.rs, add the missing editor re-export:
#[cfg(feature = "editor")]
pub use gpui_native_editor as editor;
  1. Run cargo check --workspace in the kit; fix only API drift between zed rev 504a7f2 and gpui-pre 0.3.10. Commit and record the revision.

  2. In coop's root Cargo.toml, add the dependency:

gpui-native-kit = { git = "https://git.reya.info/reya/gpui-native-kit", rev = "<new-rev>", features = ["core", "data", "layout", "editor"] }

For local iteration use path = "../gpui-native-kit". Do not add a [patch] for gpui: Cargo cannot rename the gpui-pre package into the git gpui package through [patch]; the kit manifest change above is the supported route. The old gpui-kit stays in the workspace until Step 10.

Manifests that gain gpui-native-kit = { workspace = true }: crates/assets (icons only, may use gpui-native-icons directly), crates/ui, crates/common, crates/chat_ui, crates/nip29_ui, crates/nip4e, crates/nip60_ui, crates/workspace, desktop, web. crates/speech keeps gpui-kit. Remove gpui-kit, gpui-base, native-theme, and native-theme-gpui from every other manifest in Step 9.

Step 1 — bootstrap and theme

Files: desktop/src/main.rs, web/src/lib.rs, crates/settings/src/lib.rs, crates/workspace/src/lib.rs.

  1. Startup:
    • Replace gpui_kit::init(cx) with gpui_native_kit::init(cx); followed by speech::init_compat(cx); (the latter keeps the old theme global alive for the speech components). Define it in crates/speech:
/// Initializes the old component kit that the retained speech widgets still use.
pub fn init_compat(cx: &mut gpui::App) {
    gpui_kit::init(cx);
}

desktop/web add speech = { path = "../crates/speech" }; they do not depend on gpui-kit directly. Also call common::dialogs::init(cx); common::toasts::init(cx); once at startup (Step 5 defines them).

  • Replace gpui_kit::open_window(opts, cx, workspace::init) with cx.open_window(opts, |window, cx| { gpui_native_kit::theme::Theme::sync_from_window(window, cx); gpui_native_kit::theme::Theme::sync_window_rem_size(window, cx); window.observe_window_appearance(|window, cx| { gpui_native_kit::theme::Theme::sync_from_window(window, cx); cx.refresh_windows(); }).detach(); workspace::init(window, cx) }). workspace::init already returns Entity<Workspace>.
  • Set WindowOptions.window_background = cx.theme().window_background after gpui_native_kit::init (import gpui_native_kit::theme::ActiveTheme).
  • Delete apply_native_theme, ThemeWatcher, start_theme_watcher, native_theme/native_theme_gpui imports, and the two dependencies from desktop/Cargo.toml. Delete the same from web/Cargo.toml and web/src/lib.rs.
  • Delete apply_web_fonts; the kit paints .SystemUIFont and has no UI-font token.
  1. Appearance (desktop, web, settings):

    • AppearanceMode::System → gpui_native_kit::theme::follow_system(cx).
    • Light/Dark → gpui_native_kit::theme::set_mode_override(Some(gpui_native_kit::theme::ThemeVariant::Light/Dark), cx).
    • Mirror the mode into the old theme for speech: gpui_kit::component::Theme::change(gpui_kit::component::ThemeMode::Light/Dark, None, cx).
    • In crates/workspace/src/lib.rs, replace the observe_window_appearance body with gpui_native_kit::theme::Theme::sync_from_window(window, cx); remove AppSettings::apply_theme_style(cx) and the old Theme import.
  2. Remove theme tuning from settings:

    • Keep AppearanceMode; delete ThemeSettings, theme accessor, apply_theme_style, apply_theme, and persisted_theme. settings keeps its gpui dependency (it uses App/Context) but drops gpui-kit.
    • In crates/workspace/src/dialogs/settings.rs, delete font_size_input, radius_input, radius_lg_input, wire_number_input, the font-size/radius/focus-ring rows, RADIUS_RANGE, FONT_SIZE_RANGE, and the ThemeSettings reads. Keep appearance, wallet, speech, and other non-theme rows.
    • The kit owns radii, focus chrome, and metrics; there is no public override in gpui-native_theme. If preserving these settings is required later, add a token-override API to the kit, not app-side mutation.

Checkpoint: cargo check -p coop -p coop_web -p settings.

Step 2 — icons and assets

  1. crates/assets/src/lib.rs: replace every gpui_kit::assets::Assets with gpui_native_kit::icons::IconAssets (keep the wasm OnceLock caching). Add gpui-native-kit = { workspace = true } to crates/assets/Cargo.toml; remove gpui-kit.
  2. Merge the two icon enums into one ui::IconName in crates/ui/src/icon.rs.
    • Keep all current local variants and add every old-kit variant used by coop: ArrowRight, Info, Plus, Link, X, CircleCheck, Compass, SquarePen, LogOut, LogIn, Wallet, Coins, UserRoundPlus, CircleX, Ban, Reply, Ellipsis, ChevronDown, MessageSquare, TriangleAlert, Check, PanelLeft, PanelLeftOpen, PanelRight, PanelRightOpen, PanelBottom, PanelBottomOpen, Close, WindowMinimize, WindowRestore, WindowMaximize, WindowClose, Book, Inbox.
    • Implement gpui_native_icons::IconNamed; new variants use icons/<kebab-case>.svg (e.g. CircleCheck -> "icons/circle-check.svg", X/WindowClose -> "icons/x.svg", TriangleAlert -> "icons/triangle-alert.svg"). Existing local variants keep their current string paths (Close -> "icons/close.svg", CheckCircle -> "icons/check-circle.svg").
    • Delete the gpui_kit::assets::IconNamed import.
  3. Replace every gpui_kit::assets::IconName::Variant with ui::IconName::Variant in chat_ui, nip29_ui, nip60_ui, nip4e, workspace.
  4. Replace Icon::new(x) with Icon::named(x); .small()/.xsmall() on icons become .size(cx.theme().sizes.small.icon_size) / .size(cx.theme().sizes.small.dense_icon_size) (or keep Sizable where the type implements it; Icon does not). .text_color(color) becomes .color(color).
  5. CombinedAssets falls back to IconAssets, which serves the one missing kit glyph (square.svg); no new vendored files are required.

Checkpoint: cargo check -p assets -p ui.

Step 3 — theme token sweep (all UI files)

Import gpui_native_kit::common::{h_flex, v_flex, StyledExt, Sizable} and gpui_native_kit::theme::{ActiveTheme, ThemeVariant}. The old StyledExt font helpers do not exist: .font_semibold() → .font_weight(FontWeight::SEMIBOLD), .font_medium() → .font_weight(FontWeight::MEDIUM), .font_normal() → .font_weight(FontWeight::NORMAL).

Apply this exact field mapping to every cx.theme() access (48 files):

Old (gpui-kit Theme) New (gpui_native_theme::Theme)
.background .colors.background
.foreground .colors.foreground
.border .colors.border
.primary .ramps.primary.base
.secondary, .secondary_foreground .colors.secondary, .colors.secondary_foreground
.secondary_hover, .secondary_active .ramps.secondary.hover, .ramps.secondary.pressed
.danger, .danger_foreground, .danger_active .colors.destructive, .colors.destructive_foreground, .ramps.destructive.pressed
.warning, .success .colors.warning, .colors.success
.muted, .muted_foreground .colors.muted, .colors.muted_foreground
.input, .input_background() .colors.input
.list_hover, .list_active .colors.muted_hover, .colors.selection
.sidebar, .sidebar_foreground, .sidebar_border .colors.surface, .colors.foreground, .colors.border
.tab_foreground, .tab_active, .tab_active_foreground .colors.muted_foreground, .colors.surface, .colors.foreground
.tokens.tab_bar, .tokens.background, .tokens.tab_active, .tokens.drop_target .colors.background, .colors.background, .colors.surface, .colors.selection
.group_box, .group_box_foreground .colors.surface, .colors.surface_foreground
.drag_border, .popover .colors.accent, .colors.surface
.radius, .radius_lg .radius.control, .radius.overlay
.font_family delete (use .SystemUIFont default)
.motion_tokens().duration_normal / .easing_move .motion.overlay / gpui::ease_out_quint()
.is_dark() .variant == ThemeVariant::Dark
.shadow bool delete the conditional; use .elevations.popover.shadows() where a shadow is needed

Div::rounded(cx.theme().radius) etc. become rounded(cx.theme().radius.control).

Checkpoint: cargo check --workspace (expect old-component type errors only).

Step 4 — leaf controls

Old New
Button::new(id) Button::new(id)
.label/.icon/.on_click/.disabled/.loading/.tab_stop same; .icon takes Icon (Icon::named(app_icon))
.ghost() .variant(ButtonVariant::Ghost)
.primary() / .secondary() .variant(ButtonVariant::Primary) / .variant(ButtonVariant::Secondary)
.danger() / ButtonVariant::Danger .variant(ButtonVariant::Destructive)
.warning() .variant(ButtonVariant::Destructive) (no warning variant; noted gap)
.outline() .variant(ButtonVariant::Secondary)
.text() .variant(ButtonVariant::Ghost) (padding differs)
.xsmall() .small()
.size(impl Into<Size>) .with_size(size); .size(rems(2.)) becomes .w(rems(2.)).h(rems(2.)) (styled)
.selected(bool) no equivalent; use if selected { Ghost } else { Secondary } or the owner's explicit style
.tooltip("x") .tooltip(Tooltip::text("x"))
.dropdown_menu(...), .dropdown_caret(...), .dropdown_menu_with_anchor(...) use DropdownMenu (action menus) or Menu with a trigger (arbitrary trigger element); see Step 5
Switch::new(id).checked(v).on_click(|&v,..|) .checked(v).on_change(|v,..|)
Separator::horizontal() Separator::horizontal() (same)
Spinner::new().small() Spinner::new(id).small() (id required)
Progress::new(id).value(v) Progress::new(id, v)
ProgressCircle::new(id).value(v) Spinner::new(id).value(v).small()
Clipboard::new(id).value(v).tooltip(t) ClipboardButton::new(id, v).tooltip(Tooltip::text(t))
Tooltip::new(t).build(window,cx) Tooltip::text(t) (closure is the tooltip builder)
Avatar (crates/ui) gpui_native_kit::avatar::Avatar::new(id).name(name).picture(url).fully_rounded(true)
local empty/notice views EmptyState::new(headline).hint(...).icon(...).action(...)
local chips Badge::new(label) / StatusDot::new(id).variant(...)

Avatar specifics: old Avatar::new(picture).seed(seed) becomes Avatar::new(seed).picture(picture).fully_rounded(true). The kit Avatar is not a DropdownMenu; for menus see Step 5. Delete crates/ui/src/avatar.rs, and replace all ui::Avatar imports with gpui_native_kit::avatar::Avatar.

Separator, Spinner, Progress, ClipboardButton, Tooltip, Switch, EmptyState, Badge, StatusDot come from gpui_native_kit root (gpui_native_core::* re-export).

Checkpoint: cargo check -p chat_ui -p nip29_ui -p nip60_ui -p workspace.

Step 5 — overlays, menus, dialogs, notifications

Menus

The kit has no extension traits. Menu/DropdownMenu are controlled elements: pass open and update it from on_change. Add one bool per menu to its owning view.

  • Button::dropdown_menu(|menu, window, cx| ...) → DropdownMenu::new(id).variant(ButtonVariant::Ghost).icon(...).open(self.field).on_open_change(cx.listener(|this, open, _, cx| { this.field = open; cx.notify(); })).items(move |window, cx| vec![...]).
  • PopupMenu builder → Vec<MenuItem>:
    • .menu(label, Box::new(action)) → MenuItem::new(label).handler(move |window, cx| window.dispatch_action(action.clone(), cx))
    • .menu_with_check(label, checked, action) → .checked(checked).handler(...)
    • .label(text) → MenuItem::new(text).disabled(true)
    • .separator() → MenuItem::separator()
    • .item(PopupMenuItem::new(label).on_click(...)) → MenuItem::new(label).handler(move |window, cx| ...)
    • .item(PopupMenuItem::element(...)) (logout row) → MenuItem::new("Log out").destructive(true).handler(...)
    • .submenu("React", ...) (one site, nip29_ui/message.rs) → flatten the emoji into sibling MenuItems (label the first "React" as a disabled header if desired); the kit has no submenu.
  • .context_menu(...) on a row → wrap the row in ContextMenu::new(id).target(move |window, cx| row_builder(window, cx)).items(move |_, _| vec![...]). ContextMenu owns its open state; no owner field.
  • Avatar menu (sidebar/header.rs, chat_ui avatar): wrap the Avatar in Menu::new(("avatar-menu", id)).open(self.avatar_menu == Some(id)).on_change(...).trigger(move |window, cx| avatar_element).items(...). Add avatar_menu: Option<EntityId> to Sidebar/ChatPanel/GroupPanel. For chat_ui message avatars, use a single avatar_menu: Option<EventId> on ChatPanel.
  • Popover (sidebar/header.rs): new Popover::new(id).open(self.encryption_open).on_change(...).trigger(...).content(...). The old Anchor::TopRight and popover.update(...).dismiss(...) are gone; the kit anchors below the trigger and closes on outside click/Escape. Add encryption_open: bool to Sidebar.

Dialogs

The kit Dialog is an element, and its overlay is absolute within its parent; it must be mounted at the workspace root or it is clipped by panels. Add a host in crates/common:

// crates/common/src/dialogs.rs
pub struct Dialogs { views: Vec<AnyView> }
impl Dialogs {
    pub fn open(&mut self, view: AnyView, cx: &mut Context<Self>) -> EntityId;
    pub fn close(&mut self, id: EntityId, cx: &mut Context<Self>);
    pub fn clear(&mut self, cx: &mut Context<Self>);
}
impl Render for Dialogs {
    // An absolute, window-covering layer. Every dialog view inside renders its own
    // `Dialog` element; the host only stacks them.
    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
        div().absolute().inset_0().children(self.views.iter().cloned())
    }
}
pub fn init(cx: &mut App);                       // installs the global Entity<Dialogs>
pub fn open(view: AnyView, cx: &mut App);        // pushes
pub fn close(view: EntityId, cx: &mut App);      // removes one
pub fn clear(cx: &mut App);                      // close_all_dialogs

crates/common/src/toasts.rs wraps gpui_native_kit::toast::{Toasts, Toast}:

pub fn init(cx: &mut App);
pub fn notify(toast: Toast, cx: &mut App);
pub fn clear(cx: &mut App);                      // remove_notification/clear

Workspace::render mounts both at the root, outside the dock and sidebar:

deferred(div().absolute().inset_0()
    .child(common::dialogs::global(cx).clone())
    .child(ToastsView::new("toasts", common::toasts::global(cx))))

(Observe the two entities in Workspace::new with cx.observe(&entity, |_,_,cx| cx.notify()).)

Each dialog view becomes an overlay view:

  • In its Render, wrap the existing body in Dialog::new(("dialog", self.id)).open(true).title(self.title()).on_change(move |open, window, cx| if !open { common::dialogs::close(self_id, cx) }).content(move |window,cx| body).actions(...). Dialog centers itself over the host. Capture self_id = cx.entity().entity_id() before the closures.
  • window.open_dialog(cx, builder) → create the entity (cx.new(...)), then common::dialogs::open(view.clone().into(), cx). The view stores no host state.
  • window.close_dialog(cx) → common::dialogs::close(cx.entity().entity_id(), cx) from inside the view; for child views that close a parent dialog, pass the parent dialog EntityId in the constructor or capture the parent's WeakEntity and call close(parent.entity_id(), cx).
  • window.close_all_dialogs(cx) → common::dialogs::clear(cx).
  • AlertDialog (5 sites) → Dialog::new(id).alert(true).title(...).content(...).actions(move |window,cx| vec![cancel_button, confirm_button]); explicit Buttons replace DialogButtonProps. Default cancel/ok labels are written at each site (Cancel, OK, Ignore, Response, etc.). Dialog::on_change closes the host entry; alert(true) blocks Escape/backdrop close.
  • The kit Dialog has no width builder and no title-bar close button. Preserve the old widths (380/420/460/650) by wrapping the content in div().w(px(...)); Escape and scrim click close, and alert dialogs require their action buttons.
  • DialogHeader/DialogTitle/DialogDescription/DialogFooter/DialogAction (import dialog) → plain v_flex() with .text_size(...)/.font_weight(SEMIBOLD) and the kit Separator; no component.

Notification → toast mapping:

  • window.push_notification(Notification::error(msg), cx) → common::toasts::notify(Toast::new(msg).icon(IconRole::Warning), cx).
  • .warning(msg) → .icon(IconRole::Warning); .success(msg) → .icon(IconRole::Check); NotificationType::Info → .icon(IconRole::Info).
  • .autohide(false) → .duration(std::time::Duration::ZERO).
  • .action(|...| Button...) → .action(ToastAction::new("Retry", move |cx| { ... })).
  • .title(t).message(m) → Toast::new(format!("{t} · {m}")); the kit toast has no title/body/content slot.
  • .id::<T>() and window.remove_notification::<T>() → common::toasts::clear(cx); the kit store holds one toast at a time.
  • nip4e's rich content closure (crates/nip4e/src/lib.rs:1006-1087) → a one-line toast containing the code; avatar/rich body are a noted gap.

Checkpoint: cargo check -p common -p workspace -p chat_ui -p nip29_ui -p nip60_ui -p nip4e.

Step 6 — inputs and forms

Old New
InputState::new(window, cx) InputState::new(cx) (or new_with_text(text, cx) for .default_value)
InputState::new(...).placeholder(p) Input::new(id, state).placeholder(p)
.masked(true) state.update(cx, |s,cx| s.set_password(true, cx)); add an IconRole::Eye/EyeOff ghost Button suffix toggling set_revealed for the three password fields
.clean_on_escape() delete; the kit blurs on Escape
.clearable(true) / .cleanable(true) no equivalent; for the two search inputs add a Button suffix with IconRole::Close that calls set_value("", window, cx); drop the flag elsewhere
.appearance(false) delete; the kit field always paints its chrome
TextareaState::new + .auto_grow(min,max) InputState::new(cx) + set_multiline(true, cx); render Input::new(id, state).rows(1); no auto-grow (noted gap)
Textarea::new(&state) Input::new(id, state.clone()) (multiline state)
NumberInput::new(&state) Input::new(id, state); parse/clamp in the TextChanged handler; no stepper buttons
InputEvent::PressEnter { .. } InputStateEvent::Submitted
InputEvent::Change InputStateEvent::TextChanged
state.read(cx).insert(text, window, cx) let mut value = state.read(cx).value().to_owned(); value.push_str(text); state.update(cx, |s,cx| s.set_value(value, window, cx));
state.update(cx, |s,cx| s.focus(window,cx)) window.focus(&state.read(cx).focus_handle(), cx)
.set_loading(bool, ...) delete (no loading state); show a Spinner prefix/suffix where the old spinner mattered
Input::new(&state) Input::new(id, state.clone()) (id required)

Forms:

  • settings_row(label, desc, control, cx) → Field::new(id, label).description(desc).variant(FieldVariant::Row).child(control).
  • settings_block(...) → Field::new(id, label).description(desc).child(control) (default Block).
  • Call sites that need the old fixed control column: wrap the control in h_flex().w_40().justify_end().child(control); otherwise let the kit field lay it out.
  • Delete crates/ui/src/form.rs.
  • GroupBox (new_group.rs two sites) → inline v_flex().p_3().gap_2().rounded(cx.theme().radius.overlay).border_1().border_color(cx.theme().colors.border).bg(cx.theme().colors.surface) with a title row; noted gap (no kit GroupBox).
  • Kbd (settings.rs dictation shortcut) → inline shortcut label: if cfg!(target_os="macos") { "⌘D" } else { "Ctrl+D" } in a muted div().px_1().rounded(cx.theme().radius.sm).bg(cx.theme().colors.muted); noted gap.
  • gpui_base::Select/Popup in new_group.rs: replace with kit Select<T> (Select::new(id).options(...).selected(value).on_change(...)) and Popover for the content.
  • form::{field, v_form} in dialogs/import.rs → kit Field.

Checkpoint: cargo check -p workspace -p chat_ui -p nip29_ui -p nip60_ui.

Step 7 — data views and text

  1. ScrollableElement::overflow_y_scrollbar() (4 sites) → wrap the content in ScrollArea::new(id).axes(ScrollAxes::Vertical).
  2. Scrollbar::vertical(&list_state) → Scrollbar::vertical(("chat-scroll", id), list_state.clone()); the kit trait is implemented for ListState/UniformListScrollHandle.
  3. gpui_base::{VirtualListScrollHandle, v_virtual_list} (panels/browse.rs) → gpui ListState::new(rows, ListAlignment::Top, px(100.)) + list(state, move |this, ix, window, cx| ...); remove the per-item size vector and track_scroll. scroll_to_item(0, Top) → ListState::scroll_to(ListOffset { item_ix: 0, offset_in_item: px(0.) }). Noted gap: the kit has no generic virtual list (its checklist lists it as future work).
  4. TabBar in dialogs/relays.rs → Tabs::new(id).variant(TabsVariant::Pill).selected(self.selected).on_change(...).tabs(move |_,_| vec![Tab::new(key, label)...]).
  5. Presence/Transition progress pill (sidebar/mod.rs) → render the pill unconditionally; drop the enter/exit animation. Noted gap: no transition helper.
  6. ui::text:
    • Keep InlineReplacement and rendered_text.
    • Store Entity<MarkdownViewState> per message: replace BTreeMap<EventId, RenderedText> with BTreeMap<EventId, Entity<MarkdownViewState>>. On first render of a message, create cx.new(|_| MarkdownViewState::new(("message", message.id), source)); render the entity. MarkdownViewState is immutable, so creation happens once per message.
    • Preprocess nostr:nevent1... destinations into https://njump.me/<bech32> inside rendered_text/InlineReplacement; the kit's markdown view calls cx.open_url itself and exposes no link callback. Delete is_web_url/nevent_bech32 if unused.
    • NIP-30 custom emoji images become their alt text (kit markdown renders images as alt text). Noted gap.
    • RevealText stays as-is (plain text, no kit dependency).
  7. WelcomeMessage → EmptyState::new(title).hint(message).icon(icon) where the caller can supply a headline; otherwise keep the product view. Delete only if the mapping is lossless.

Checkpoint: cargo check -p chat_ui -p nip29_ui -p workspace.

Step 8 — dock, shell, panels

Delete crates/workspace/src/dock/{mod.rs, skin.rs, tab_panel.rs, invalid_panel.rs, window_controls.rs} and replace with crates/workspace/src/dock.rs.

  1. Workspace owns dock: Entity<gpui_native_kit::layout::dock::DockState> and sidebar: Entity<Sidebar>.
  2. Root layout (manual, because Shell::left_sidebar only accepts the data Sidebar, not an arbitrary view):
div().flex().flex_row().size_full()
    .when(self.sidebar_visible, |row| row
        .child(div().w(self.sidebar_width).h_full().child(self.sidebar.clone()))
        .child(ResizeHandle::new("sidebar-resize").on_resize(...)))
    .child(div().flex_1().min_w_0().h_full().child(
        Dock::new("coop-dock", self.dock.clone())
            .window_controls(!cfg!(target_os = "macos"))
            .traffic_light_pad(cfg!(target_os = "macos"))))

Add sidebar_visible: bool (initial true), sidebar_width: Pixels (default 240; clamp 160..480) to Workspace. Add workspace::ToggleSidebar action and a Button in the sidebar header with IconRole::PanelLeft; handle it in Workspace.

Initial content: create the dock state, then in cx.defer_in open greeter::init(dock.downgrade(), window, cx) into the dock exactly as the old center region did. Do not register every panel at startup; panels register on first open through open_panel. 3. Register/open panels with a helper:

pub fn open_panel<P: Render + 'static>(
    dock: &WeakEntity<DockState>,
    title: impl Into<SharedString>,
    view: Entity<P>,
    split: Option<SplitDirection>,
    window: &mut Window,
    cx: &mut App,
) {
    let id = SharedString::from(view.entity_id().to_string());
    dock.update(cx, |dock, cx| {
        let view = view.clone();
        dock.register_panel(PanelDescriptor::new(id.clone(), title, move |_, _| view.clone().into()));
        dock.open_panel(&id, window, cx);
        if let Some(direction) = split { dock.split_panel(&id, direction, cx); }
    }).ok();
}

Call sites: sidebar/rows.rs::open_room, sidebar/inbox.rs, dialogs/new_chat.rs, dialogs/new_group.rs, panels/browse.rs, workspace/src/lib.rs. Replace WeakEntity<DockArea> with WeakEntity<DockState> in those signatures. 4. Remove Panel/BasePanel/PanelEvent impls from ChatPanel, GroupPanel, WalletPanel, BackupPanel, BrowsePanel, ContactListPanel, GreeterPanel, InboxPanel, Sidebar. Remove EventEmitter<PanelEvent>, panel_name, title, toolbar_buttons, set_active, on_removed, closable, zoomable. Panel titles/icons move to PanelDescriptor at registration (static; dynamic rename is a noted gap). Delete the now-unused nip29_ui::{ShowGroupMeta, HideGroupMeta} actions and their handlers in crates/workspace/src/lib.rs. 5. Replace TAB_BAR_HEIGHT with cx.theme().titlebar_height; use it for the sidebar header height and the window traffic-light position (point(px(9.), px(theme.titlebar_height / 2. - 14. / 2.))). Use gpui_native_kit::layout::window_controls::{titlebar_strip, TRAFFIC_LIGHT_PAD} in the sidebar header; delete ui::title_bar_drag_handlers and crates/ui/src/window_drag.rs. 6. Remove ClosePanel/ToggleZoom dispatch and handlers. The new dock's own close/zoom controls own those actions. In screening.rs, the Ignore button now only closes the dialog (no panel close). 7. Sidebar is no longer a dock panel; its BasePanel::set_active group-view dispatch is removed. Row clicks already call Sidebar::show_group; keep that path. Behavior change: activating an already-open group panel no longer opens the sidebar's group view, and closing a group panel no longer hides it; the back button still hides it.

Checkpoint: cargo check -p workspace -p chat_ui -p nip29_ui -p nip60_ui.

Step 9 — remove the old design system

  1. crates/ui: delete avatar.rs, form.rs, window_drag.rs. Keep icon.rs, message.rs, text.rs. Update lib.rs. Remove gpui-base, gpui-kit, and dead deps (uuid, web-time, log); add gpui-native-kit.
  2. crates/speech is the only crate that keeps gpui-kit. Make it re-export the speech UI types so chat_ui/nip29_ui do not import gpui_kit:
pub use gpui_kit::component::speech::{SpeechButton, SpeechEvent, SpeechState, SpeechWaveform};

Change chat_ui/nip29_ui imports to speech::{...}. speech::init_compat calls gpui_kit::init(cx); keep the old-theme mode mirror from Step 1. Remove gpui-kit from chat_ui, nip29_ui, nip60_ui, nip4e, settings, assets, workspace, ui, desktop, web. 3. Workspace Cargo.toml: remove gpui-base, native-theme, native-theme-gpui; keep gpui-kit (speech only). Remove gpui-base from all crate manifests. 4. Delete crates/workspace/src/dock/{skin,tab_panel,invalid_panel,window_controls}.rs; delete CoopDockSkin, CoopTabGroupSkin, DragPanelPreview, DockResizeTracker, ResizePanel, ControlIcon. 5. No crate other than crates/speech imports gpui_kit. crates/speech keeps the old speech types, ActiveTheme, Subscription, and init_compat.

Checkpoint: cargo check --workspace; rg "gpui_kit::|gpui_base::" crates desktop web must report only crates/speech (init_compat and the speech re-exports).

Kit gaps (explicitly noted, no new components invented)

Missing in gpui-native-kit Handling in this plan
Window-level notifications with severity, title, rich content, ids, system delivery Kit Toast only: one toast at a time, one line, optional icon/action/duration. Hosted in common; rich nip4e body reduced to text.
Speech (SpeechState, SpeechButton, SpeechWaveform, SpeechRecognizer, AudioInput) Kept on old gpui-kit in crates/speech; the only old-kit dependency left.
Markdown inline images (NIP-30 emoji) and a link-click callback Kit MarkdownViewState renders images as alt text and opens links through cx.open_url; rendered_text preprocesses nostr:nevent to a njump URL.
Menu submenus, arbitrary-element rows, non-action label rows, anchor override Flatten the single React submenu; use MenuItem::new(label).disabled(true) for labels; MenuItem::disabled(true)/destructive(true) for custom rows; anchor below trigger.
Button text variant, child content, attached dropdown, selected state, warning variant, xsmall Map to Ghost/Secondary/Destructive variants and DropdownMenu/Menu; avatar-as-trigger uses Menu::trigger.
Input clear button, mask toggle, loading state, auto-grow, number stepper, borderless appearance, escape-clear, insert/focus Compose suffix buttons where needed; otherwise drop the affordance and note it. Fixed rows replace auto-grow.
GroupBox, Kbd, NumberInput, ProgressCircle, Presence/Transition, Lerp/easing helpers Inline box/label compositions; ProgressCircle → determinate Spinner; transitions dropped.
Generic virtual list Use gpui list/uniform_list with ListState; no kit component.
Window-level dialog/notification managers; dialog width/close button common::dialogs host + explicit action buttons; width on content; no title-bar X.
Old theme tokens (danger, list_*, sidebar*, tab_*, tokens.*, group_box, font_family) and token overrides Mapped in Step 3; font-size/radius/focus-ring settings removed (kit owns tokens).
Dock placements (left/right/bottom), panel lifecycle/toolbar/actions, dynamic tab titles One pane tree; left panels open as tabs or split left; lifecycle hooks removed; titles static.
Generic rows with arbitrary prefix/suffix Nav stays as product-specific code, rebased on kit tokens; kit List/Sidebar cannot express it.
Facade editor re-export Added in Step 0; otherwise depend on gpui-native-editor directly.
IconRole coverage of app glyphs App ui::IconName implements IconNamed; kit IconAssets supplies missing role glyphs.

Critical files

  • desktop/src/main.rs and web/src/lib.rs — startup, theme, window.
  • crates/workspace/src/lib.rs — dock root, sidebar layout, dialogs/toasts host, actions.
  • crates/workspace/src/dock.rs (new) — open_panel helper; replaces the old dock module.
  • crates/ui/src/message.rs — largest product-view rebase (buttons, menus, theme tokens).
  • crates/common/src/dialogs.rs, crates/common/src/toasts.rs (new) — kit lacks window-level managers.

Verification

  1. cargo check --workspace in /Users/reya/Documents/Git/coop.
  2. cargo build -p coop (desktop).
  3. Run cargo run -p coop -- --sec <throwaway nsec> and check:
    • Window opens with the kit titlebar; no old dock tab bar or double titlebar.
    • Sidebar renders avatars, icons, hover/selected rows; sidebar resize and toggle work.
    • User avatar menu opens and shows Profile/Relays/Log out; encryption popover opens and closes.
    • Cmd/Ctrl+, Preferences opens; appearance Light/Dark/System changes the whole UI immediately; the speech shortcut row shows the platform shortcut.
    • Open a chat from the sidebar; send hello; the message renders; right-click the message opens the context menu; the emoji menu opens; a toast appears for an empty send.
    • A nevent1... mention opens njump.me on click.
    • Drag a dock tab to an edge; the pane splits; close/close-others work.
    • Open Browse; scrolling uses the list; open New Group and New Chat dialogs; cancel closes them.
  4. cargo check -p coop_web --target wasm32-unknown-unknown (requires the wasm target and gpui_web); verify the web bundle opens a window and settings appearance works.
  5. rg -n "gpui_kit::|gpui_base::|native_theme" crates desktop web — only crates/speech, the compat gpui_kit::init lines, and the speech imports should remain.

Assumptions and contingencies

  • The kit's gpui dependency moves to gpui-pre 0.3.10 as a committed prerequisite. If the kit does not compile against 0.3.10, bump coop's gpui, gpui_platform, gpui_web, and gpui-kit to the newest 0.3.x that compiles both, together; never keep two gpui crates.
  • The kit repo is modifiable. If it must stay untouched, vendor it under vendor/gpui-native-kit and apply the same two manifest edits there.
  • Theme tuning (font size, focus ring, radii) is removed because the new theme has no public override. If it must be preserved, add a token-override API to the kit; do not mutate the global.
  • Speech is retained through the old kit. If old theme/keybinding initialization conflicts with the new kit, replace gpui_kit::init(cx) with the minimal old-component theme init (gpui_kit::component::Theme::change after new theme init) and verify SpeechButton/SpeechWaveform render.
  • Dock placements are not preserved 1:1: the new dock has one pane tree. Left-placed panels open as tabs, optionally split left via split_panel; bottom/right placements are not used by coop. Panel tab titles are static after registration.