diff --git a/README.md b/README.md index e022a7e..04ab8a6 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,9 @@ feedback. The current accepted signed build is `0.1.0-alpha.6`; `0.1.0-alpha.5` is an immutable rejected artifact. The current source candidate is -`0.1.0-alpha.7`. Signed builds are shared directly with invited testers. +`0.1.0-alpha.7`. It allows drafting the next command while one runs and lets a +user visit Home without disconnecting the one active SSH session. Signed builds +are shared directly with invited testers. There is no public signed APK or GitHub prerelease while Phase 5 is open. Testers should obtain the APK and its checksum from the owner through the agreed private channel, then follow the @@ -107,6 +109,8 @@ connection setup, and saved transcript history. restoration, local drafting while a turn runs, copy, edit, rerun, output collapsing, selectable output, confirmed HTTP(S) links, and raw-terminal switching +- One-active-session Home navigation with explicit Return and Disconnect + actions; a second connection remains disabled - Advisory detection of alternate-screen, cursor-addressing, mouse-tracking, and bracketed-paste control sequences with an explicit same-session terminal handoff diff --git a/app/src/androidTest/java/dev/threadline/ConnectionFormRetentionTest.kt b/app/src/androidTest/java/dev/threadline/ConnectionFormRetentionTest.kt index 0f576e8..f09b647 100644 --- a/app/src/androidTest/java/dev/threadline/ConnectionFormRetentionTest.kt +++ b/app/src/androidTest/java/dev/threadline/ConnectionFormRetentionTest.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsNotSelected import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onAllNodesWithText @@ -70,6 +71,44 @@ class ConnectionFormRetentionTest { compose.runOnIdle { assertEquals(1, helpOpenCount) } } + @Test + fun activeSessionCanBeReopenedOrDisconnectedWithoutStartingAnotherConnection() { + var returnCount = 0 + var disconnectCount = 0 + + compose.setContent { + MaterialTheme { + HostForm( + draft = ConnectionFormDraft.emptyDefaults(), + onDraftChange = {}, + sessionError = null, + activeSessionDisplayName = "Barnabas", + connectionEnabled = false, + onReturnToActiveSession = { returnCount += 1 }, + onDisconnectActiveSession = { disconnectCount += 1 }, + onPrepared = { error("A second connection must stay disabled.") }, + ) + } + } + + compose.onNodeWithTag(ConnectionFormTags.ACTIVE_SESSION).assertExists() + compose.onNodeWithText("Barnabas").assertExists() + compose.onNodeWithTag(ConnectionFormTags.CONNECT) + .performScrollTo() + .assertIsNotEnabled() + + compose.onNodeWithTag(ConnectionFormTags.RETURN_TO_SESSION) + .performScrollTo() + .performClick() + compose.onNodeWithTag(ConnectionFormTags.DISCONNECT_SESSION) + .performScrollTo() + .performClick() + compose.runOnIdle { + assertEquals(1, returnCount) + assertEquals(1, disconnectCount) + } + } + @Test fun blankPasswordUsesProductionSafeValidationCopy() { compose.setContent { diff --git a/app/src/androidTest/java/dev/threadline/TranscriptScreenTest.kt b/app/src/androidTest/java/dev/threadline/TranscriptScreenTest.kt index cfa61a1..6ec6d17 100644 --- a/app/src/androidTest/java/dev/threadline/TranscriptScreenTest.kt +++ b/app/src/androidTest/java/dev/threadline/TranscriptScreenTest.kt @@ -480,6 +480,35 @@ class TranscriptScreenTest { .assertIsDisplayed() } + @Test + fun connectedSessionHomeActionDoesNotDisconnect() { + var homeCount = 0 + var disconnectCount = 0 + + composeRule.setContent { + MaterialTheme { + ConnectedSessionScreen( + displayName = "Test session", + structuredShell = StructuredShellState.Ready("/tmp"), + transcript = CommandTranscriptState(), + onSubmit = { + CommandSubmissionResult.Accepted(CommandId("unused")) + }, + onControlC = {}, + onDisconnect = { disconnectCount += 1 }, + onOpenHome = { homeCount += 1 }, + rawTerminal = { modifier -> Text("Terminal", modifier = modifier) }, + ) + } + } + + composeRule.onNodeWithTag(TranscriptTags.HOME).performClick() + composeRule.runOnIdle { + assertEquals(1, homeCount) + assertEquals(0, disconnectCount) + } + } + @Test fun boundedLargeOutputCanExpandAndSwitchViewsWithinFiveSeconds() { val output = buildString { @@ -668,6 +697,7 @@ class TranscriptScreenTest { SemanticsMatcher.keyIsDefined(SemanticsProperties.Heading), ) composeRule.onNodeWithText("Terminal").performScrollTo().assertIsDisplayed() + composeRule.onNodeWithText("Home").performScrollTo().assertIsDisplayed() composeRule.onNodeWithText("Diagnostics").performScrollTo().assertIsDisplayed() composeRule.onNodeWithText("Disconnect").performScrollTo().assertIsDisplayed() } diff --git a/app/src/main/java/dev/threadline/MainActivity.kt b/app/src/main/java/dev/threadline/MainActivity.kt index 4b539b1..69251e8 100644 --- a/app/src/main/java/dev/threadline/MainActivity.kt +++ b/app/src/main/java/dev/threadline/MainActivity.kt @@ -42,6 +42,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -49,6 +50,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -179,6 +181,9 @@ internal object ConnectionFormTags { const val SAVE_PRIVATE_KEY = "connection-save-private-key" const val EPHEMERAL = "connection-ephemeral" const val CONNECT = "connection-connect" + const val ACTIVE_SESSION = "connection-active-session" + const val RETURN_TO_SESSION = "connection-return-to-session" + const val DISCONNECT_SESSION = "connection-disconnect-session" const val SAVED_KEY_PREFIX = "connection-saved-key-" const val RENAME_KEY_PREFIX = "connection-rename-key-" const val DELETE_KEY_PREFIX = "connection-delete-key-" @@ -222,10 +227,18 @@ private fun ThreadlineApp() { mutableStateOf(onboardingPreferences.shouldShowIntroduction()) } var selectedHostProfileId by rememberSaveable { mutableStateOf(null) } + var showConnectedSession by rememberSaveable { mutableStateOf(true) } + val connectedSessionStateHolder = rememberSaveableStateHolder() var diagnosticGeneratedAtMillis by remember { mutableStateOf(null) } val diagnosticEnvironment = remember(context) { androidDiagnosticEnvironment(context) } val openDiagnostics = { diagnosticGeneratedAtMillis = System.currentTimeMillis() } + LaunchedEffect(state is SessionState.Connected) { + if (state !is SessionState.Connected) { + connectedSessionStateHolder.removeState("active-session") + } + } + val permissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission(), ) { granted -> @@ -236,7 +249,9 @@ private fun ThreadlineApp() { } } - val canShowIntroduction = state is SessionState.Disconnected || state is SessionState.Failed + val canShowIntroduction = state is SessionState.Disconnected || + state is SessionState.Failed || + state is SessionState.Connected && !showConnectedSession if (showIntroduction && canShowIntroduction) { OnboardingScreen( onContinue = { @@ -244,13 +259,31 @@ private fun ThreadlineApp() { showIntroduction = false }, ) + } else if (state is SessionState.Connected && showConnectedSession) { + connectedSessionStateHolder.SaveableStateProvider("active-session") { + ConnectedSessionScreen( + displayName = state.displayName, + structuredShell = snapshot.structuredShell, + transcript = snapshot.transcript, + onSubmit = manager::submitCommand, + onControlC = manager::sendControlC, + onDisconnect = manager::disconnect, + onOpenHome = { showConnectedSession = false }, + onOpenDiagnostics = openDiagnostics, + ) + } } else when (val current = state) { SessionState.Disconnected, is SessionState.Failed, + is SessionState.Connected, -> HostForm( draft = connectionDraft, onDraftChange = { connectionDraft = it }, sessionError = (current as? SessionState.Failed)?.error, + activeSessionDisplayName = (current as? SessionState.Connected)?.displayName, + connectionEnabled = current !is SessionState.Connected, + onReturnToActiveSession = { showConnectedSession = true }, + onDisconnectActiveSession = manager::disconnect, hostProfiles = hostProfiles, selectedHostProfileId = selectedHostProfileId, onSelectedHostProfileChange = { selectedHostProfileId = it }, @@ -274,6 +307,7 @@ private fun ThreadlineApp() { onOpenNotificationSettings = { openNotificationSettings(context) }, onPrepared = prepared@{ request -> if (!manager.prepareConnection(request)) return@prepared false + showConnectedSession = true if ( Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && @@ -290,16 +324,6 @@ private fun ThreadlineApp() { }, ) - is SessionState.Connected -> ConnectedSessionScreen( - displayName = current.displayName, - structuredShell = snapshot.structuredShell, - transcript = snapshot.transcript, - onSubmit = manager::submitCommand, - onControlC = manager::sendControlC, - onDisconnect = manager::disconnect, - onOpenDiagnostics = openDiagnostics, - ) - is SessionState.Connecting -> ProgressScreen( title = current.displayName, status = current.stage.name.lowercase().replaceFirstChar(Char::uppercase), @@ -393,6 +417,10 @@ internal fun HostForm( draft: ConnectionFormDraft, onDraftChange: (ConnectionFormDraft) -> Unit, sessionError: SessionError?, + activeSessionDisplayName: String? = null, + connectionEnabled: Boolean = true, + onReturnToActiveSession: () -> Unit = {}, + onDisconnectActiveSession: () -> Unit = {}, hostProfiles: List = emptyList(), selectedHostProfileId: String? = null, onSelectedHostProfileChange: (String?) -> Unit = {}, @@ -529,6 +557,51 @@ internal fun HostForm( .padding(24.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { + activeSessionDisplayName?.let { displayName -> + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(ConnectionFormTags.ACTIVE_SESSION), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(16.dp), + ) { + Text( + "Active session", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.semantics { heading() }, + ) + Text(displayName, style = MaterialTheme.typography.bodyLarge) + Text( + "This session remains connected while you use Home.", + style = MaterialTheme.typography.bodyMedium, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = onReturnToActiveSession, + modifier = Modifier.testTag( + ConnectionFormTags.RETURN_TO_SESSION, + ), + ) { + Text("Return") + } + TextButton( + onClick = onDisconnectActiveSession, + modifier = Modifier.testTag( + ConnectionFormTags.DISCONNECT_SESSION, + ), + ) { + Text("Disconnect") + } + } + Text( + "Disconnect this session before connecting to another server.", + style = MaterialTheme.typography.bodySmall, + ) + } + } + } Text( "Connect to a server", style = MaterialTheme.typography.titleMedium, @@ -1072,7 +1145,7 @@ internal fun HostForm( } } }, - enabled = !isBusy, + enabled = connectionEnabled && !isBusy, modifier = Modifier .fillMaxWidth() .testTag(ConnectionFormTags.CONNECT), diff --git a/app/src/main/java/dev/threadline/TranscriptScreen.kt b/app/src/main/java/dev/threadline/TranscriptScreen.kt index 4ea6d41..fa22826 100644 --- a/app/src/main/java/dev/threadline/TranscriptScreen.kt +++ b/app/src/main/java/dev/threadline/TranscriptScreen.kt @@ -92,6 +92,7 @@ internal object TranscriptTags { const val TERMINAL_ALT = "terminal-alt" const val SUBMISSION_ERROR = "command-submission-error" const val SESSION_ACTIONS = "session-actions" + const val HOME = "session-home" private const val TERMINAL_KEY_PREFIX = "terminal-key-" private const val OUTPUT_PREFIX = "command-output-" @@ -128,6 +129,7 @@ internal fun ConnectedSessionScreen( onSubmit: (String) -> CommandSubmissionResult, onControlC: () -> Unit, onDisconnect: () -> Unit, + onOpenHome: () -> Unit = {}, onOpenDiagnostics: () -> Unit = {}, rawTerminal: @Composable (Modifier) -> Unit = { RawTerminal(it) }, ) { @@ -176,6 +178,12 @@ internal fun ConnectedSessionScreen( if (showingRawTerminal) { TextButton(onClick = onControlC) { Text("Ctrl-C") } } + TextButton( + onClick = onOpenHome, + modifier = Modifier.testTag(TranscriptTags.HOME), + ) { + Text("Home") + } TextButton( onClick = onOpenDiagnostics, modifier = Modifier.testTag(DiagnosticTags.OPEN), diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index de9ae23..92b212e 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -139,21 +139,23 @@ and the rule that history must not accidentally become credential storage. ## Home navigation and session dashboard -**Status:** Deferred product architecture; current UI owns one visible active -session. +**Status:** One retained active session implemented in the alpha.7 source; +multiple concurrent sessions deferred. -Threadline currently requires disconnecting before returning to host and -history management. Separate two possible scopes: +Threadline now allows navigation Home while retaining the one active +foreground-service-backed session. Home identifies the active session, offers +explicit Return and Disconnect actions, and prevents a second connection while +the first remains active. Leaving the session screen does not disconnect it. -1. Allow navigation home while retaining one active foreground-service-backed - session, with an obvious route back and an equally obvious disconnect state. -2. Consider multiple concurrent sessions only as a larger session-manager - feature with resource limits, per-session notifications, credential - lifetime, failure isolation, transcript ownership, process-death recovery, - and explicit close/disconnect semantics. +The remaining scope is separate: -Do not imply that leaving a session screen disconnected it, or that a retained -session survived when only its archived transcript remains. +Consider multiple concurrent sessions only as a larger session-manager feature +with resource limits, per-session notifications, credential lifetime, failure +isolation, transcript ownership, process-death recovery, and explicit +close/disconnect semantics. + +Do not imply that a retained session survived when only its archived transcript +remains. ## Raw-terminal IME focus reliability diff --git a/docs/HISTORY.md b/docs/HISTORY.md index 4d74f3e..bfeebd6 100644 --- a/docs/HISTORY.md +++ b/docs/HISTORY.md @@ -84,6 +84,10 @@ CI passed, and the permanent-key alpha.6 installed over alpha.5 with retained state intact. Password and retained imported-key authentication, Diagnostics, structured commands, and same-session raw-terminal behavior passed on the Galaxy S25 Ultra, making alpha.6 the accepted tester build. +The alpha.7 source then made the composer available for local drafting while a +command runs and added Home navigation that retains one active SSH session with +explicit Return and Disconnect actions. Multiple concurrent sessions remain +out of scope. Additional device and OEM coverage is opportunistic alpha evidence rather than a separate Pixel gate. See [STATUS.md](STATUS.md) rather than this chronology for the active boundary. diff --git a/docs/STATUS.md b/docs/STATUS.md index 7eeebdc..3c6caaf 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -109,7 +109,11 @@ candidate is `0.1.0-alpha.7` (`10007`). Its first slice keeps the structured composer editable while a command runs, preserves that local draft through same-session terminal switching and Android saved-state restoration, and keeps Send disabled until the shell returns to ready. It does not queue or -automatically execute commands. +automatically execute commands. Its second slice adds a Home route that retains +the one active SSH session, shows that session with explicit Return and +Disconnect actions, and prevents starting a second connection. Connected-screen +draft and mode state survive the round trip and are cleared when the session +actually ends. ## Remaining Phase 5 boundaries