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)
- 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.
- In
- 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" }
- 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;
-
Run
cargo check --workspacein the kit; fix only API drift between zed rev504a7f2andgpui-pre0.3.10. Commit and record the revision. -
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.
- Startup:
- Replace
gpui_kit::init(cx)withgpui_native_kit::init(cx);followed byspeech::init_compat(cx);(the latter keeps the old theme global alive for the speech components). Define it incrates/speech:
- Replace
/// 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)withcx.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::initalready returnsEntity<Workspace>. - Set
WindowOptions.window_background = cx.theme().window_backgroundaftergpui_native_kit::init(importgpui_native_kit::theme::ActiveTheme). - Delete
apply_native_theme,ThemeWatcher,start_theme_watcher,native_theme/native_theme_gpuiimports, and the two dependencies fromdesktop/Cargo.toml. Delete the same fromweb/Cargo.tomlandweb/src/lib.rs. - Delete
apply_web_fonts; the kit paints.SystemUIFontand has no UI-font token.
-
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 theobserve_window_appearancebody withgpui_native_kit::theme::Theme::sync_from_window(window, cx); removeAppSettings::apply_theme_style(cx)and the oldThemeimport.
-
Remove theme tuning from
settings:- Keep
AppearanceMode; deleteThemeSettings,themeaccessor,apply_theme_style,apply_theme, andpersisted_theme.settingskeeps itsgpuidependency (it usesApp/Context) but dropsgpui-kit. - In
crates/workspace/src/dialogs/settings.rs, deletefont_size_input,radius_input,radius_lg_input,wire_number_input, the font-size/radius/focus-ring rows,RADIUS_RANGE,FONT_SIZE_RANGE, and theThemeSettingsreads. 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.
- Keep
Checkpoint: cargo check -p coop -p coop_web -p settings.
Step 2 — icons and assets
crates/assets/src/lib.rs: replace everygpui_kit::assets::Assetswithgpui_native_kit::icons::IconAssets(keep the wasmOnceLockcaching). Addgpui-native-kit = { workspace = true }tocrates/assets/Cargo.toml; removegpui-kit.- Merge the two icon enums into one
ui::IconNameincrates/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 useicons/<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::IconNamedimport.
- Keep all current local variants and add every old-kit variant used by coop:
- Replace every
gpui_kit::assets::IconName::Variantwithui::IconName::Variantinchat_ui,nip29_ui,nip60_ui,nip4e,workspace. - Replace
Icon::new(x)withIcon::named(x);.small()/.xsmall()on icons become.size(cx.theme().sizes.small.icon_size)/.size(cx.theme().sizes.small.dense_icon_size)(or keepSizablewhere the type implements it;Icondoes not)..text_color(color)becomes.color(color). CombinedAssetsfalls back toIconAssets, 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![...]).PopupMenubuilder →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 siblingMenuItems (label the first"React"as a disabled header if desired); the kit has no submenu.
.context_menu(...)on a row → wrap the row inContextMenu::new(id).target(move |window, cx| row_builder(window, cx)).items(move |_, _| vec![...]).ContextMenuowns its open state; no owner field.- Avatar menu (
sidebar/header.rs,chat_uiavatar): wrap the Avatar inMenu::new(("avatar-menu", id)).open(self.avatar_menu == Some(id)).on_change(...).trigger(move |window, cx| avatar_element).items(...). Addavatar_menu: Option<EntityId>toSidebar/ChatPanel/GroupPanel. Forchat_uimessage avatars, use a singleavatar_menu: Option<EventId>onChatPanel. Popover(sidebar/header.rs): newPopover::new(id).open(self.encryption_open).on_change(...).trigger(...).content(...). The oldAnchor::TopRightandpopover.update(...).dismiss(...)are gone; the kit anchors below the trigger and closes on outside click/Escape. Addencryption_open: booltoSidebar.
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 inDialog::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(...).Dialogcenters itself over the host. Captureself_id = cx.entity().entity_id()before the closures. window.open_dialog(cx, builder)→ create the entity (cx.new(...)), thencommon::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 dialogEntityIdin the constructor or capture the parent'sWeakEntityand callclose(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]); explicitButtons replaceDialogButtonProps. Default cancel/ok labels are written at each site (Cancel,OK,Ignore,Response, etc.).Dialog::on_changecloses the host entry;alert(true)blocks Escape/backdrop close.- The kit
Dialoghas no width builder and no title-bar close button. Preserve the old widths (380/420/460/650) by wrapping the content indiv().w(px(...)); Escape and scrim click close, and alert dialogs require their action buttons. DialogHeader/DialogTitle/DialogDescription/DialogFooter/DialogAction(import dialog) → plainv_flex()with.text_size(...)/.font_weight(SEMIBOLD)and the kitSeparator; 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>()andwindow.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)(defaultBlock).- 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.rstwo sites) → inlinev_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.rsdictation shortcut) → inline shortcut label:if cfg!(target_os="macos") { "⌘D" } else { "Ctrl+D" }in a muteddiv().px_1().rounded(cx.theme().radius.sm).bg(cx.theme().colors.muted); noted gap.gpui_base::Select/Popupinnew_group.rs: replace with kitSelect<T>(Select::new(id).options(...).selected(value).on_change(...)) andPopoverfor the content.form::{field, v_form}indialogs/import.rs→ kitField.
Checkpoint: cargo check -p workspace -p chat_ui -p nip29_ui -p nip60_ui.
Step 7 — data views and text
ScrollableElement::overflow_y_scrollbar()(4 sites) → wrap the content inScrollArea::new(id).axes(ScrollAxes::Vertical).Scrollbar::vertical(&list_state)→Scrollbar::vertical(("chat-scroll", id), list_state.clone()); the kit trait is implemented forListState/UniformListScrollHandle.gpui_base::{VirtualListScrollHandle, v_virtual_list}(panels/browse.rs) → gpuiListState::new(rows, ListAlignment::Top, px(100.))+list(state, move |this, ix, window, cx| ...); remove the per-item size vector andtrack_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).TabBarindialogs/relays.rs→Tabs::new(id).variant(TabsVariant::Pill).selected(self.selected).on_change(...).tabs(move |_,_| vec![Tab::new(key, label)...]).Presence/Transitionprogress pill (sidebar/mod.rs) → render the pill unconditionally; drop the enter/exit animation. Noted gap: no transition helper.ui::text:- Keep
InlineReplacementandrendered_text. - Store
Entity<MarkdownViewState>per message: replaceBTreeMap<EventId, RenderedText>withBTreeMap<EventId, Entity<MarkdownViewState>>. On first render of a message, createcx.new(|_| MarkdownViewState::new(("message", message.id), source)); render the entity.MarkdownViewStateis immutable, so creation happens once per message. - Preprocess
nostr:nevent1...destinations intohttps://njump.me/<bech32>insiderendered_text/InlineReplacement; the kit's markdown view callscx.open_urlitself and exposes no link callback. Deleteis_web_url/nevent_bech32if unused. - NIP-30 custom emoji images become their alt text (kit markdown renders images as alt text). Noted gap.
RevealTextstays as-is (plain text, no kit dependency).
- Keep
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.
Workspaceownsdock: Entity<gpui_native_kit::layout::dock::DockState>andsidebar: Entity<Sidebar>.- Root layout (manual, because
Shell::left_sidebaronly accepts the dataSidebar, 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
crates/ui: deleteavatar.rs,form.rs,window_drag.rs. Keepicon.rs,message.rs,text.rs. Updatelib.rs. Removegpui-base,gpui-kit, and dead deps (uuid,web-time,log); addgpui-native-kit.crates/speechis the only crate that keepsgpui-kit. Make it re-export the speech UI types sochat_ui/nip29_uido not importgpui_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.rsandweb/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_panelhelper; 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
cargo check --workspacein/Users/reya/Documents/Git/coop.cargo build -p coop(desktop).- 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 opensnjump.meon 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.
cargo check -p coop_web --target wasm32-unknown-unknown(requires the wasm target andgpui_web); verify the web bundle opens a window and settings appearance works.rg -n "gpui_kit::|gpui_base::|native_theme" crates desktop web— onlycrates/speech, the compatgpui_kit::initlines, and the speech imports should remain.
Assumptions and contingencies
- The kit's
gpuidependency moves togpui-pre0.3.10 as a committed prerequisite. If the kit does not compile against 0.3.10, bump coop'sgpui,gpui_platform,gpui_web, andgpui-kitto the newest 0.3.x that compiles both, together; never keep twogpuicrates. - The kit repo is modifiable. If it must stay untouched, vendor it under
vendor/gpui-native-kitand 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::changeafter new theme init) and verifySpeechButton/SpeechWaveformrender. - 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.