Back to Blog

Multi-Window Chat App in Compose Desktop | Kotlin Desktop #13

Sandy LaneSandy Lane

Video: Multi-Window Chat App in Compose Desktop | Kotlin Desktop #13 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Multi-Window Apps: Build a Chat App with System Tray

Multiple Window { ... } blocks inside one application. A Tray icon for the menu bar. Shared state via a top-level object. Per-contact chat windows that open and close independently.

Today's app is a small chat client. A contact list in the main window; clicking a contact opens that conversation in its own window. A tray icon in the menu/system bar. Standard desktop multi-window UX in Compose.

What we are building

A 380×700 contact list window. Click any contact → a 600×700 chat window opens. Multiple chats can be open at once, each in its own window. A tray icon with "Show Contacts" and "Quit" sits in the system tray. Send a message in any chat — a canned reply auto-arrives 1.5s later.

Project layout

src/main/kotlin/
├── Main.kt           # application + windows + tray
├── ContactList.kt    # main window content
├── ChatWindow.kt     # chat-window content
└── ChatData.kt       # ChatState singleton + types

Multiple windows from one application

fun main() = application {
  Window(onCloseRequest = ::exitApplication, title = "Chat") {
    ContactList(onContactClick = { ChatState.openChat(it) })
  }

  for (contactId in ChatState.openChats.toList()) {
    val contact = ChatState.contacts.first { it.id == contactId }
    key(contactId) {
      Window(
        onCloseRequest = { ChatState.closeChat(contactId) },
        title = "Chat — ${contact.name}",
      ) {
        ChatWindowContent(contactId)
      }
    }
  }
}

application { ... } is a Composable scope. Anything Composable inside it can render — including multiple Window blocks. The framework keeps a window alive as long as the Window composable is in the tree, and disposes it when it leaves.

The pattern is:

  • One main Window always renders.
  • A for loop over ChatState.openChats renders one Window per open chat.
  • When a chat is closed (ChatState.closeChat(id) removes it from the list), its window leaves composition, and the OS-level window closes.

key { ... } for stable window identities

key(contactId) {
  Window(...) { ChatWindowContent(contactId) }
}

Compose tracks composables by position in the tree. When the order of items in openChats changes (you close a middle chat, the others shift index), Compose without a key would think the second window is now the first — and reuse the wrong state.

Wrapping each window in key(contactId) ties the window's identity to its ID. The window for "alice" stays "alice's window" even if you close someone above it.

Shared state: the ChatState singleton

object ChatState {
  val contacts = listOf(/* ... */)
  val messages = mutableStateMapOf<String, SnapshotStateList<Message>>()
  val openChats = mutableStateListOf<String>()

  fun sendMessage(contactId: String, text: String) {
    getMessages(contactId).add(Message(contactId, text, fromMe = true))
  }

  fun openChat(contactId: String) {
    if (contactId !in openChats) openChats.add(contactId)
  }

  fun closeChat(contactId: String) { openChats.remove(contactId) }
}

object ChatState is a Kotlin singleton — a single instance accessible from anywhere. Reading and mutating its mutableStateListOf/mutableStateMapOf fields drives recomposition in any window that reads them.

That's the magic of shared state across windows: each Window's composition reads from ChatState, so when you mutate from one window, the others see the change immediately.

For a real app, you'd inject this via a DI framework (Lesson 16) instead of a global singleton. For two windows that share data, a singleton is fine.

Tray icon

val TrayIcon = run {
  val img = BufferedImage(32, 32, BufferedImage.TYPE_INT_ARGB)
  val g = img.createGraphics()
  g.color = java.awt.Color(103, 80, 164)
  g.fillRoundRect(0, 0, 32, 32, 8, 8)
  g.color = java.awt.Color.WHITE
  g.font = java.awt.Font("SansSerif", java.awt.Font.BOLD, 20)
  g.drawString("C", /* centered */)
  g.dispose()
  img.toComposeImageBitmap()
}

Tray(
  icon = BitmapPainter(TrayIcon),
  menu = {
    Item("Show Contacts") { /* ... */ }
    Item("Quit") { exitApplication() }
  },
)

Tray adds a menu-bar/system-tray icon. The icon is a Painter — typically built from an image resource (PNG) or, in our case, drawn programmatically with Java AWT for a self-contained sample.

The menu slot defines the right-click menu. Item("Label") { onClick } is one entry. Add as many as you want; group with Separator().

exitApplication() is provided by the application { ... } scope and tears down all windows.

Per-window state

@Composable
fun ChatWindowContent(contactId: String) {
  val messages = ChatState.getMessages(contactId)
  var inputText by remember { mutableStateOf("") }
  val listState = rememberLazyListState()
  // ...
}

inputText is local to this window's composition — each chat window has its own input field, its own scroll state. But messages reads from ChatState, the shared singleton — closing and reopening a chat preserves the messages.

This is the right split:

  • Local state (input text, scroll position, focus) lives in each window.
  • Shared state (messages, contacts, open windows list) lives in ChatState.

Auto-scroll on new messages

LaunchedEffect(messages.size) {
  if (messages.isNotEmpty()) listState.animateScrollToItem(messages.lastIndex)
}

LaunchedEffect(messages.size) re-runs whenever the size changes. Inside, listState.animateScrollToItem(lastIndex) smoothly scrolls to the newest message — no jumping, no manual maths.

rememberLazyListState() plus LazyColumn(state = listState) is the canonical pattern for controlling scroll programmatically.

Auto-reply

LaunchedEffect(messages.size) {
  val last = messages.lastOrNull()
  if (last != null && last.fromMe) {
    delay(1500)
    val reply = ChatState.cannedResponses.random()
    ChatState.getMessages(contactId).add(Message(contactId, reply, fromMe = false))
  }
}

Same key (messages.size) — when you send, the size changes, the effect fires. It checks last.fromMe so the reply doesn't trigger off the auto-reply itself, sleeps 1.5s, then adds a canned response. Demo behaviour — in a real app, this would be a Ktor call to your chat backend.

Sending: Enter key handling

OutlinedTextField(
  value = inputText,
  onValueChange = { inputText = it },
  modifier = Modifier.onPreviewKeyEvent {
    if (it.key == Key.Enter && it.type == KeyEventType.KeyDown) {
      send(); true
    } else false
  },
  // ...
)

Modifier.onPreviewKeyEvent { event -> ... } intercepts key events before they reach the field's default handlers. We catch Key.Enter on key-down, call send(), and return true to signal "consumed" (so Enter doesn't insert a newline).

Returning false lets the event continue to the default handlers — needed for arrow keys, copy-paste, etc.

WindowState: positioning multiple windows

state = rememberWindowState(
  position = WindowPosition((420 + index * 100).dp, (40 + index * 60).dp),
  size = DpSize(600.dp, 700.dp),
)

Each chat window gets a slightly offset position so they cascade visually rather than stacking on top of each other. index is the position in openChats, so the third window is shifted by 2 × 100 = 200dp from the second.

WindowPosition.PlatformDefault lets the OS pick. Explicit positions give predictable test screenshots.

Avatars from data

data class Contact(
  val id: String,
  val name: String,
  val avatarColor: Color,
  val status: String,
)

Box(
  modifier = Modifier.size(36.dp).clip(CircleShape).background(contact.avatarColor),
  contentAlignment = Alignment.Center,
) {
  Text(contact.name.first().toString(), color = Color.White, fontWeight = FontWeight.Bold)
}

A coloured circle plus the first letter of the name. No image assets, fully data-driven, looks decent. Same trick used in real chat apps when an image hasn't loaded.

MessageBubble

@Composable
fun MessageBubble(message: Message, contact: Contact) {
  val alignment = if (message.fromMe) Alignment.CenterEnd else Alignment.CenterStart
  val bgColor = if (message.fromMe) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant
  val textColor = if (message.fromMe) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant

  Box(modifier = Modifier.fillMaxWidth(), contentAlignment = alignment) {
    Surface(
      shape = RoundedCornerShape(12.dp),
      color = bgColor,
      modifier = Modifier.widthIn(max = 320.dp),
    ) {
      Text(message.text, modifier = Modifier.padding(10.dp), color = textColor)
    }
  }
}

Three switches off fromMe: alignment (right vs left), background colour, text colour. widthIn(max = 320.dp) caps the bubble — long messages wrap rather than spanning the whole window.

ChatState patterns: snapshots and reactivity

val messages = mutableStateMapOf<String, SnapshotStateList<Message>>()

A reactive map of reactive lists. Reading messages["alice"] from a Composable subscribes to changes in both the map (added/removed contacts) and the list (added/removed messages).

getOrPut lazily initialises lists for new contacts:

fun getMessages(contactId: String): SnapshotStateList<Message> {
  return messages.getOrPut(contactId) { mutableStateListOf() }
}

Common mistakes

Mutating ChatState from a non-main thread. Compose state is main-thread only. If a callback fires off the main thread (a network response on a Ktor IO thread, for instance), wrap the mutation in Dispatchers.Main.

Forgetting key(id) around windows. Closing a non-last window mid-list breaks state for the rest. Always key.

Storing window state in the singleton. inputText, scrollState, focusRequester — these are per-window. Keep them in the window's composition with remember. Save only data (messages, openChats) in the singleton.

No onCloseRequest. Without it, closing the X button kills your whole app — application { } exits when no windows remain. Wire onCloseRequest = { ChatState.closeChat(id) } for child windows.

Cross-window animation that fights itself. Each window has its own composition, so animations running in two windows on the same shared state can flicker. Centralise the animation in one window if it represents shared progress.

What's next

Lesson 14: sortable tables and data grids. A spreadsheet-style data grid with sorting, filtering, pagination, and selection. The widget every business app needs.

Recap

Multiple Window { ... } blocks inside one application. Tray(icon, menu) for system tray. key(id) around dynamic windows for stable identity. Shared state in a top-level object with mutableStateListOf / mutableStateMapOf. onPreviewKeyEvent for Enter-to-send. LaunchedEffect(size) plus listState.animateScrollToItem for auto-scroll on new messages.

Next lesson: sortable data grids.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.