feat(#533): make /fork work via chat (session.branch) (#565)

* feat(#533): add session.branch (fork a conversation) to mobile

The gateway already supports session.branch (tui_gateway/server.py), but
the mobile had no client surface. This adds:
- WsMethods.SESSION_BRANCH = "session.branch"
- A Branch (CallSplit) action on each session card that forks it via the
  session.branch WS RPC with the source session_id (and optional name).
- branchedSessionId state -> SessionsScreen navigates to the freshly
  forked session on success; RpcError -> toast; spinner while branching.
- SessionsViewModelTest (4 tests): forwards session_id, forwards name,
  sets branchedSessionId on success, toast+no-branch on RpcError, and
  consumeBranchedSession clears the pending id.

Verified against the live gateway contract: session.branch expects
{session_id, name?} and returns {session_id, title, parent}. Local
testDebugUnitTest: 4/4 pass, ktlint clean.

* refactor(#533): make /fork work via chat instead of a session-menu button

Per review feedback: the session.branch surface belongs in the chat
composer, not the session list menu.

- SlashCommandDispatcher: /fork and /branch -> new SlashResult.SessionBranch.
- ChatViewModel: handles SessionBranch by sending session.branch with the
  active runtime session id (optional arg = branch title), and on the RPC
  result switches the current session to the new forked one (mirrors the
  SESSION_CREATE handler). This fixes /fork, which previously fell through to
  command.dispatch and 4018'd (backend only knows session.branch).
- Removed the Branch (CallSplit) button + navigation LaunchedEffect from
  SessionsScreen/SessionsViewModel (reverted those two files to main) and
  dropped the now-unused sessions_action_branch string.
- Tests: deleted SessionsViewModelTest (branch-only); added /fork -> SessionBranch
  to SlashCommandDispatcherTest and two ChatViewModel tests asserting /fork
  sends session.branch with the session id + optional name.

Local testDebugUnitTest: SlashCommandDispatcherTest 8/8, ChatViewModelTest 50/50,
0 failures. ktlint clean.

---------

Co-authored-by: ronia <ronia@m57>
This commit is contained in:
M57andronia authored and GitHub committed 2026-07-12 18:34:07 +03:00
1 parent 07764955d3
commit ad87cd1054
5 files changed
+106

No files matched your search

@@ -12,6 +12,7 @@ object WsMethods {
const val SESSION_INTERRUPT = "session.interrupt"
const val SESSION_DELETE = "session.delete"
const val SESSION_TITLE = "session.title"
const val SESSION_BRANCH = "session.branch"
// ── Interaction ───────────────────────────────────────────────────────
const val PROMPT_SUBMIT = "prompt.submit"
@@ -466,6 +466,25 @@ class ChatViewModel(
loadSessions()
}
WsMethods.SESSION_BRANCH -> {
val resultMap = result as? Map<String, Any?> ?: return
val newId = resultMap["session_id"] as? String ?: return
runtimeSessionId = newId
_uiState.update {
it.copy(
currentSessionId = newId,
isLoading = false,
messages = emptyList(),
chatTitle = (resultMap["title"] as? String)?.takeIf { t -> t.isNotBlank() } ?: "Hermes",
)
}
ActiveSessionHolder.set(newId)
_streamingState.update { StreamingState() }
addSystemMessage("Session branched", persist = true)
loadSessionMessages(newId)
loadSessions()
}
WsMethods.SESSION_LIST -> {
val resultMap = result as? Map<String, Any?> ?: return
val sessionsList = resultMap["sessions"] as? List<Map<String, Any?>> ?: return
@@ -790,12 +809,40 @@ class ChatViewModel(
createNewSession()
}
is SlashResult.SessionBranch -> {
branchSession(command)
}
is SlashResult.RpcDispatch -> {
dispatchViaRpc(command)
}
}
}
/**
* Fork the active conversation via the session.branch WS RPC (issue #533).
* The backend already supports session.branch; the mobile previously had
* no client surface, so `/fork` fell through to command.dispatch and 4018'd.
* The optional arg becomes the new branch's title.
*/
private fun branchSession(command: String) {
val sessionId = runtimeSessionId
if (sessionId == null) {
addAssistantMessage("No active session. Use `/new` to create one.")
return
}
val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim()
val params = mutableMapOf<String, Any>("session_id" to sessionId)
if (arg.isNotBlank()) params["name"] = arg
viewModelScope.launch(Dispatchers.IO) {
wsClient.send(
WsMethods.SESSION_BRANCH,
params,
onSent = { id -> trackRequest(id, WsMethods.SESSION_BRANCH) },
)
}
}
private fun dispatchViaRpc(command: String) {
val sessionId = runtimeSessionId
if (sessionId == null) {
@@ -17,6 +17,7 @@ class SlashCommandDispatcher {
return when (cmd) {
"/stop", "/interrupt" -> SlashResult.Interrupt
"/new" -> SlashResult.NewSession
"/fork", "/branch" -> SlashResult.SessionBranch
else -> SlashResult.RpcDispatch
}
}
@@ -34,4 +35,7 @@ sealed class SlashResult {
/** Forward to command.dispatch via WebSocket. */
data object RpcDispatch : SlashResult()
/** Fork the active conversation via the session.branch WebSocket RPC. */
data object SessionBranch : SlashResult()
}