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

@@ -190,6 +190,51 @@ class ChatViewModelTest {
verify(atLeast = 1) { HermesWsClient.send(WsMethods.SESSION_CREATE, any(), any()) }
}
@Test
fun testSlashCommand_fork_sendsSessionBranch() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
val captured = mutableListOf<Pair<String, Map<String, Any>>>()
every { HermesWsClient.send(any(), any(), any()) } answers {
val id = "req-${captured.size + 1}"
captured.add(arg<String>(0) to (arg<Map<String, Any>>(1)))
arg<((String) -> Unit)?>(2)?.invoke(id)
id
}
// /fork with an optional branch title.
viewModel.sendMessage("/fork my-fork")
advanceUntilIdle()
val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH }
assertNotNull("session.branch should be dispatched for /fork", branchSent)
assertEquals(sessionId, branchSent!!.second["session_id"])
assertEquals("my-fork", branchSent.second["name"])
}
@Test
fun testSlashCommand_fork_withoutName_omitsNameParam() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
val captured = mutableListOf<Pair<String, Map<String, Any>>>()
every { HermesWsClient.send(any(), any(), any()) } answers {
val id = "req-${captured.size + 1}"
captured.add(arg<String>(0) to (arg<Map<String, Any>>(1)))
arg<((String) -> Unit)?>(2)?.invoke(id)
id
}
viewModel.sendMessage("/fork")
advanceUntilIdle()
val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH }
assertNotNull("session.branch should be dispatched for /fork", branchSent)
assertEquals(sessionId, branchSent!!.second["session_id"])
assertFalse("name param should be omitted when no title given", branchSent.second.containsKey("name"))
}
@Test
fun testSlashCommand_stop_sendsInterrupt() =
runTest {
@@ -34,6 +34,15 @@ class SlashCommandDispatcherTest {
assertEquals(SlashResult.NewSession, dispatcher.dispatch("/new"))
}
@Test
fun `fork routes to SessionBranch`() {
// /fork (and /branch) fork the active conversation via session.branch —
// the backend's command, not command.dispatch. See issue #533.
assertEquals(SlashResult.SessionBranch, dispatcher.dispatch("/fork"))
assertEquals(SlashResult.SessionBranch, dispatcher.dispatch("/branch"))
assertEquals(SlashResult.SessionBranch, dispatcher.dispatch("/FORK hello"))
}
@Test
fun `NEW uppercase still routes to NewSession`() {
// Dispatcher lower-cases before matching, so case must not matter.