* 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:
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()
|
||||
}
|
||||
Reference in new issue
Block a user