Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
30 changes: 30 additions & 0 deletions app/src/androidTest/java/dev/threadline/TranscriptScreenTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
}
Expand Down
97 changes: 85 additions & 12 deletions app/src/main/java/dev/threadline/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,15 @@ 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
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
Expand Down Expand Up @@ -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-"
Expand Down Expand Up @@ -222,10 +227,18 @@ private fun ThreadlineApp() {
mutableStateOf(onboardingPreferences.shouldShowIntroduction())
}
var selectedHostProfileId by rememberSaveable { mutableStateOf<String?>(null) }
var showConnectedSession by rememberSaveable { mutableStateOf(true) }
val connectedSessionStateHolder = rememberSaveableStateHolder()
var diagnosticGeneratedAtMillis by remember { mutableStateOf<Long?>(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 ->
Expand All @@ -236,21 +249,41 @@ 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 = {
onboardingPreferences.markIntroductionComplete()
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 },
Expand All @@ -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 &&
Expand All @@ -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),
Expand Down Expand Up @@ -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<SavedHostProfile> = emptyList(),
selectedHostProfileId: String? = null,
onSelectedHostProfileChange: (String?) -> Unit = {},
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1072,7 +1145,7 @@ internal fun HostForm(
}
}
},
enabled = !isBusy,
enabled = connectionEnabled && !isBusy,
modifier = Modifier
.fillMaxWidth()
.testTag(ConnectionFormTags.CONNECT),
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/java/dev/threadline/TranscriptScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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-"

Expand Down Expand Up @@ -128,6 +129,7 @@ internal fun ConnectedSessionScreen(
onSubmit: (String) -> CommandSubmissionResult,
onControlC: () -> Unit,
onDisconnect: () -> Unit,
onOpenHome: () -> Unit = {},
onOpenDiagnostics: () -> Unit = {},
rawTerminal: @Composable (Modifier) -> Unit = { RawTerminal(it) },
) {
Expand Down Expand Up @@ -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),
Expand Down
26 changes: 14 additions & 12 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions docs/HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 5 additions & 1 deletion docs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down