Multi-Window Chat App in Compose Desktop | Kotlin Desktop #13
Video: Multi-Window Chat App in Compose Desktop | Kotlin Desktop #13 by Taught by Celeste AI - AI Coding Coach
Multi-Window Apps: Build a Chat App with System Tray
Multiple
Window { ... }blocks inside oneapplication. ATrayicon for the menu bar. Shared state via a top-levelobject. 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
Windowalways renders. - A
forloop overChatState.openChatsrenders oneWindowper 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.