Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
7a1a462caf
19 files changed
+1333
-105
No files matched your search
@@ -245,6 +245,57 @@ first if a remote spawn ever mangles an argument.
|
||||
on the same transcript. What that costs is that the rows below slide up
|
||||
under the reader's finger, so a row that has just moved ignores taps for
|
||||
half a second (`SETTLE_MS`).
|
||||
- **An answered question keeps its options and marks the one that was
|
||||
taken**, in the same purple that says "picked" while it is still open --
|
||||
it does not collapse into a line repeating the answer. The options are
|
||||
what the question *was*, and "Deny" alone does not say that Allow was the
|
||||
alternative. One rule in two places (`AskedQuestion` and `PermissionAsk`),
|
||||
since a permission is a question with two bare options rather than a
|
||||
different kind of thing. An answer typed into **Other** matches no option,
|
||||
so that one is still written out -- the state the marking cannot say.
|
||||
- **Anything that is a note *about* the conversation rather than a turn in
|
||||
it is closed by default**: a tool call, a peer message, and now a memory
|
||||
note (`<cc-memory>`). Open-ness is the screen's, never the card's -- a
|
||||
card that remembered for itself forgets the moment the lazy list stops
|
||||
composing it, so a note opened and scrolled past would shut behind the
|
||||
reader.
|
||||
- **The full-screen image lives on the screen, not in the row that drew the
|
||||
thumbnail** (`SessionImageViewer`). A `Read` whose result is an image is a
|
||||
row of one call until the next call arrives and makes it a group -- a
|
||||
different composable in a different part of the tree, so the old subtree
|
||||
and everything it remembered goes, the open dialog included. Somebody
|
||||
looking at a screenshot was thrown back to the transcript because the
|
||||
session made another tool call. `/tools n gap` puts an image on its first
|
||||
call so this is reproducible: open it, wait a gap, watch the row regroup.
|
||||
- **All transcript text is selectable, from one `SelectionContainer` around
|
||||
the whole list** (`TranscriptList.kt`). Not per row: a transcript is one
|
||||
body of text to a reader, so a selection has to be able to run from a
|
||||
reply into the tool output under it -- and a container per row leaves
|
||||
whatever was drawn without one silently unselectable, which nothing on
|
||||
screen reports. Rows keep their tap handlers; selection is a long press.
|
||||
- **A session can be moved to another directory** from the settings dialog
|
||||
(`POST /sessions/{id}/cwd`). It stops the process, because a working
|
||||
directory is settled at spawn; the next message starts it in the new one.
|
||||
**`claude --resume <id>` finds a session from any directory** -- measured
|
||||
on 2.1.237 -- so nothing of Claude Code's is relocated, and should you ever
|
||||
be tempted, its project directory is the path with every non-alphanumeric
|
||||
character replaced by `-`, cut at 200 characters with a hash appended, and
|
||||
overridable besides.
|
||||
- **A message from another agent reaches a live session on the turn's
|
||||
`result`, not before.** Measured on CLI 2.1.237 by sending a real
|
||||
cross-session message to a real stream-json session: no `user` record, and
|
||||
nothing in the partial-message stream -- the whole of it is an `origin`
|
||||
object on the `result`, the same shape the session file records, which is
|
||||
why `import::peer_message` reads both. So the note is drawn *after* the
|
||||
reply it caused; that is the wire, not a bug. See PLAN.md.
|
||||
- **A queued message can be tapped to take it back**, which is
|
||||
`POST /sessions/{id}/unqueue` and a `messageDropped` event -- see PLAN.md's
|
||||
"Taking a queued message back". On a **Claude** session it always refuses,
|
||||
and that is correct rather than broken: the driver writes a steer into the
|
||||
CLI the moment it arrives, so what the bubble is waiting for is the CLI
|
||||
*reading* it, not this server sending it. The refusal is drawn on the
|
||||
bubble. The echo driver really does hold its queue, so that is the rig for
|
||||
the case where the drop succeeds.
|
||||
- **Deleting a session offers to take the machine's own transcript with
|
||||
it.** `DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the
|
||||
confirmation, and only where the driver keeps a record of its own
|
||||
@@ -602,6 +653,20 @@ machine belongs in `~/.claude/TOOLCHAIN.md` (toolchain versions) or
|
||||
further means a chunked backwards reader.
|
||||
`RUST_LOG=ai_server=debug` logs each page with what was asked and what
|
||||
came back, which is how to see a phone paging back in real time.
|
||||
- **Paging back has two failures that look like "there is simply no more
|
||||
history", and neither says anything on screen.** Both fixed 2026-08-31,
|
||||
both invisible on a loopback server and reproducible at `--delay 150`.
|
||||
The pager fires on the *first layout*, before any event has arrived --
|
||||
`moreHistory` starts true, so the history spinner is in the list and
|
||||
`visibleItemsInfo` is not empty -- and `before = 0` asks for the events
|
||||
before the first one, which is none, which is exactly how this code is
|
||||
told it has reached the start. `loadOlderPage` refuses `oldestSeq == 0`
|
||||
now. And `joinPages` only ran `adoptRun` on the path where a *split* call
|
||||
had been found, so a boundary landing cleanly between two calls -- most of
|
||||
them -- left one run of tool calls drawn as two groups with the seam
|
||||
wherever the reader happened to have paged. Reproducing either takes a
|
||||
boundary placed on purpose: the opening page is 80 events, so arrange the
|
||||
transcript so that event counts back from the newest.
|
||||
- **A page is 800 events and a screen is a handful of rows, and the two
|
||||
have no fixed ratio.** A run of thirty-five tool calls is one row; a reply
|
||||
is hundreds of text deltas folded into one. So anything that budgets in
|
||||
|
||||
@@ -247,6 +247,96 @@ turn. Claude's dialect: a `user` message on stdin mid-stream; pi's: `steer`.
|
||||
- Images in: base64 image content blocks in the stream-json user message.
|
||||
- Working directory, host, and model are spawn-screen fields.
|
||||
|
||||
### Moving a session to another directory (decided 2026-08-31)
|
||||
|
||||
`POST /sessions/{id}/cwd {cwd}`, behind a field in the session settings
|
||||
dialog. The directory is settled when the process is spawned -- the CLI is
|
||||
launched with it as its cwd and there is no control request that changes one
|
||||
-- so this records the new one and **ends** the process that is in the old
|
||||
one. It does not start a replacement: a session with no process starts on
|
||||
the next thing said to it or on Start, which is this app's rule for that
|
||||
everywhere else, and "usually restarts" would be a worse control than
|
||||
"always stops" (starting one here would have to wait for the recorded status
|
||||
to catch up with a process already gone).
|
||||
|
||||
The path is checked against the session's own machine and **refused** if it
|
||||
is not there, rather than corrected. The spawn path corrects instead,
|
||||
because it is resuming a directory the *machine* recorded and that can be
|
||||
gone through nobody's fault; a path somebody has just typed is different,
|
||||
and a mistyped one accepted here would surface much later as a session that
|
||||
would not start, with nothing pointing at the typo.
|
||||
|
||||
**Nothing of Claude Code's own is moved**, and that is a measurement rather
|
||||
than an omission. Checked against CLI 2.1.237 on 2026-08-31: `claude
|
||||
--resume <id>` finds a session from any working directory — an id that does
|
||||
not exist answers "No conversation found with session ID", and a real one
|
||||
resumed from an unrelated directory did not. So the conversation continues
|
||||
in the new place with nothing relocated, and the session file stays under
|
||||
the project directory the CLI made for it, which is where the CLI itself
|
||||
looks. Relocating it would mean reproducing a rule this app cannot see the
|
||||
whole of: the CLI's project directory is the path with every non-alphanumeric
|
||||
character replaced by `-`, truncated at 200 characters with a hash of its own
|
||||
appended, and an override can replace the name entirely.
|
||||
|
||||
While fixing this: `SessionInfo.cwd` came from the snapshot a session
|
||||
launched with, so a moved session reported its *old* directory for as long
|
||||
as the process lived. It is read from the config where the row is built now,
|
||||
the same way `setup_name` already was and for the same reason.
|
||||
|
||||
### A message from another agent, on a live session (measured 2026-08-31)
|
||||
|
||||
Peer messages were only ever produced by the *import* path, reading them out
|
||||
of the CLI's own session file — so a message another agent sent a session
|
||||
this server was running never appeared at all, and the session simply
|
||||
started working on something nobody on the phone had asked for.
|
||||
|
||||
Measured rather than guessed, by sending a real cross-session message to a
|
||||
real `--input-format stream-json` session on CLI 2.1.237: the CLI emits **no
|
||||
`user` record** for it, and nothing in the partial-message stream mentions
|
||||
it. The whole of it arrives as an `origin` object on the turn's `result`, in
|
||||
the same shape the session file records — `kind: "peer"`, the sending
|
||||
session's `name`, and the message as `body` — so `import::peer_message` reads
|
||||
both, and there is one function for one wire format. Only peer-caused turns
|
||||
carry it: four ordinary results on a real session's stdout had no `origin`
|
||||
between them.
|
||||
|
||||
**The cost is the position.** The note lands after the reply it caused rather
|
||||
than above it, because at no earlier point in the turn does the CLI say why
|
||||
the turn started. The alternative is a second reader tailing the CLI's own
|
||||
session file for the one record stdout does not carry — two sources of truth
|
||||
for one conversation and a poll per live session — and it was rejected on
|
||||
that. If the CLI ever announces the injection at the point it happens, this
|
||||
moves to that record and the ordering comes right with it.
|
||||
|
||||
### Taking a queued message back (decided 2026-08-31)
|
||||
|
||||
A message sent into a running turn is drawn as a bubble waiting below the
|
||||
transcript, and tapping it asks the server to drop it before the session
|
||||
reads it — `POST /sessions/{id}/unqueue {messageId}`, answered by
|
||||
`Driver::unqueue` and recorded as `Event::MessageDropped` so that every
|
||||
device watching loses the bubble and a reconnect does not replay it back.
|
||||
|
||||
The answer has **three** states rather than a yes/no, and that is the whole
|
||||
of the design: `Dropped`, `AlreadySent`, and `Unknown`. The reason is that
|
||||
the Claude driver can only ever give the middle one. It writes a steer into
|
||||
the CLI's stdin the instant it arrives — that is what makes a steer reach
|
||||
the model at the next tool boundary instead of at the end of the turn, and
|
||||
it was measured (see `Queue`'s doc comment) — so the line is gone before the
|
||||
phone could ask for it back. What waits in `awaiting` is the *announcement*,
|
||||
not the message.
|
||||
|
||||
Holding the write until a boundary was considered and rejected on 2026-08-31:
|
||||
it would make the drop real everywhere, but it costs a steer one model call,
|
||||
which is the latency the immediate write was introduced to remove. So the
|
||||
refusal is the honest answer and it is reported where the reader pressed —
|
||||
on the bubble itself, not in the screen's error row, which is under the
|
||||
header a screen away. What a tap buys on a Claude session is therefore
|
||||
knowing that the session has already been told; on a driver that really does
|
||||
hold a queue (echo today) the message goes.
|
||||
|
||||
`Unknown` is not "we could not find out": a driver that is gone reported
|
||||
everything it was holding when it closed, so there is nothing waiting.
|
||||
|
||||
### Session processes outlive the backend (decided 2026-08-29)
|
||||
|
||||
A session's process is **left running when the backend stops, and adopted
|
||||
@@ -659,6 +749,7 @@ GET /sessions list (id, provider, host, title, model, st
|
||||
POST /sessions spawn {provider, host, model, cwd, permission_mode, title}
|
||||
GET /sessions/:id/events?after=N SSE: transcript replay from N, then live
|
||||
POST /sessions/:id/message {text, attachment_ids}
|
||||
POST /sessions/:id/unqueue {message_id} take back one not read yet
|
||||
POST /sessions/:id/answer {question_id, answer} (questions and permissions)
|
||||
POST /sessions/:id/interrupt stop the running turn; the process stays
|
||||
POST /sessions/:id/stop end the process; the session and transcript stay
|
||||
@@ -895,6 +986,19 @@ level: the session on screen is registered by the one composable that draws
|
||||
one, and "the app is up" *is* the banner queue being collected, since it
|
||||
collects only while it is on screen.
|
||||
|
||||
**What counts as finished** is decided in `notification_for`, and since
|
||||
2026-08-31 it takes the number of messages the session has been given and
|
||||
not started reading. With one waiting, a turn ending is not the work
|
||||
ending: a message written into the tail of a turn is read the moment that
|
||||
turn's `result` lands, so the session goes idle and immediately runs again
|
||||
-- and the phone that sent it was told its work had finished, seconds
|
||||
before any of it was done. The count is kept in `pump` from the recorded
|
||||
events (`MessageQueued` up, the `UserMessage` that resolves it or a
|
||||
`MessageDropped` down), because that is the one place that sees every event
|
||||
in transcript order. It does not suppress *awaiting input*: a question is
|
||||
worth saying whatever is queued behind it, and the queue is exactly what
|
||||
will not move until it is answered.
|
||||
|
||||
The alternative considered and rejected was giving the app its own
|
||||
connection to `/notifications` while it is in front. That is a second stream
|
||||
per device saying the same thing, and it puts the "which of these two shows
|
||||
|
||||
@@ -149,6 +149,14 @@ data class SessionSummary(
|
||||
* on when a backend is too old to say, which matches what that backend actually does.
|
||||
*/
|
||||
val notify: Boolean,
|
||||
/**
|
||||
* The directory the session works in, or null where it was never given one.
|
||||
*
|
||||
* Null is not "the home directory": it is the session never having been told, and what the
|
||||
* process then starts in belongs to whatever launches it. Shown as unset rather than filled in
|
||||
* with a guess, so a reader changing it is choosing rather than confirming.
|
||||
*/
|
||||
val cwd: String?,
|
||||
/**
|
||||
* How much context this session is holding, as the server last measured it -- see
|
||||
* `SessionEvent.UsageDelta`.
|
||||
@@ -184,6 +192,7 @@ private fun parseSession(session: JSONObject) =
|
||||
permissionMode = session.optString("permissionMode").ifEmpty { null },
|
||||
imported = session.optBoolean("imported", false),
|
||||
notify = session.optBoolean("notify", true),
|
||||
cwd = session.optString("cwd").ifEmpty { null },
|
||||
contextTokens =
|
||||
if (session.has("contextTokens")) session.getLong("contextTokens") else null,
|
||||
maxImageEdge = session.optInt("maxImageEdge", 0).takeIf { it > 0 },
|
||||
@@ -492,6 +501,44 @@ fun sendMessage(
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes back a message the session has not read yet, named by the id its `messageQueued` carried.
|
||||
*
|
||||
* Throws rather than returning an outcome, because both ways of failing are things the reader has
|
||||
* to be told: 409 means the session was already given it, and 404 means nothing is waiting under
|
||||
* that id. The bubble disappearing is the success case and it arrives on the event stream, not from
|
||||
* here -- every device drops it, not only the one that tapped.
|
||||
*/
|
||||
fun unqueueMessage(settings: ServerSettings, sessionId: String, messageId: String) {
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/sessions/$sessionId/unqueue",
|
||||
method = "POST",
|
||||
jsonBody = JSONObject().put("messageId", messageId).toString(),
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a session to a different working directory.
|
||||
*
|
||||
* The server checks the directory is there on that machine and refuses if it is not -- a mistyped
|
||||
* path accepted here would surface much later, as a session that would not start, with nothing
|
||||
* pointing at the typo.
|
||||
*
|
||||
* Its process is **stopped**, because a working directory is settled when the process is spawned.
|
||||
* The next thing said to the session starts it again in the new one, which is this app's rule for a
|
||||
* session with no process everywhere else.
|
||||
*/
|
||||
fun setSessionCwd(settings: ServerSettings, sessionId: String, cwd: String) {
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/sessions/$sessionId/cwd",
|
||||
method = "POST",
|
||||
jsonBody = JSONObject().put("cwd", cwd).toString(),
|
||||
readTimeoutMs = 30000,
|
||||
) {}
|
||||
}
|
||||
|
||||
/** Uploads one picked image; the returned id goes into [sendMessage]. */
|
||||
fun uploadAttachment(
|
||||
settings: ServerSettings,
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
@@ -70,27 +71,38 @@ fun AskedQuestion(ask: TranscriptItem.QuestionCard, onAnswer: (List<String>) ->
|
||||
}
|
||||
Text(ask.prompt, style = MaterialTheme.typography.bodyLarge)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
if (ask.answers.isNotEmpty()) {
|
||||
// Joined for reading only: they arrived as a list and stay one everywhere else.
|
||||
Text(
|
||||
"Answered: ${ask.answers.joinToString(", ")}",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
return@Column
|
||||
}
|
||||
if (ask.multiSelect) {
|
||||
// An answered question keeps its options and marks the one that was taken, rather than
|
||||
// replacing them with a line repeating it. The options are what the question *was*, and
|
||||
// dropping them leaves an answer with nothing to have been an answer to -- "Sonnet" says
|
||||
// very little without the three it was chosen over. Marked in the same purple that says
|
||||
// "picked" while the question is still open, so it is one appearance learned once.
|
||||
val answered = ask.answers.isNotEmpty()
|
||||
if (ask.multiSelect && !answered) {
|
||||
MultipleChoice(ask.options, onAnswer)
|
||||
} else if (ask.options.all { it.description == null && it.preview == null }) {
|
||||
// Nothing to read, so nothing to lay out: Allow and Deny are two words, and two words
|
||||
// do not need a card each.
|
||||
AnswerOptions(ask.options, onAnswer)
|
||||
AnswerOptions(ask.options, ask.answers, onAnswer.takeUnless { answered })
|
||||
} else {
|
||||
ask.options.forEach { option ->
|
||||
OptionCard(option, selected = false) { onAnswer(listOf(option.label)) }
|
||||
OptionCard(option, selected = option.label in ask.answers) {
|
||||
if (!answered) onAnswer(listOf(option.label))
|
||||
}
|
||||
}
|
||||
OtherAnswer(onAnswer)
|
||||
}
|
||||
// What was answered in the reader's own words, which no option can mark -- see
|
||||
// [OtherAnswer]. Only ever the answers that match nothing offered, so a question answered
|
||||
// by picking says it by the mark alone.
|
||||
val inWords = ask.answers.filterNot { answer -> ask.options.any { it.label == answer } }
|
||||
if (inWords.isNotEmpty()) {
|
||||
Text(
|
||||
"Answered: ${inWords.joinToString(", ")}",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
if (!answered) OtherAnswer(onAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,14 +233,41 @@ private fun OtherAnswer(onAnswer: (List<String>) -> Unit) {
|
||||
* way for a list of choices to be wrong.
|
||||
*/
|
||||
@Composable
|
||||
fun AnswerOptions(options: List<QuestionOption>, onAnswer: (List<String>) -> Unit) {
|
||||
fun AnswerOptions(
|
||||
options: List<QuestionOption>,
|
||||
/** What was chosen, marked rather than restated; empty while the question is open. */
|
||||
answers: List<String> = emptyList(),
|
||||
/** Null once the question is answered -- the buttons stay, and stop being buttons. */
|
||||
onAnswer: ((List<String>) -> Unit)?,
|
||||
) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
options.forEach { option ->
|
||||
OutlinedButton(onClick = { onAnswer(listOf(option.label)) }) { Text(option.label) }
|
||||
val taken = option.label in answers
|
||||
OutlinedButton(
|
||||
onClick = { onAnswer?.invoke(listOf(option.label)) },
|
||||
// Disabled rather than removed, so an answered question still shows what it
|
||||
// offered. Material dims a disabled button's own border and label, which would
|
||||
// take the mark with it -- both are stated here instead.
|
||||
enabled = onAnswer != null,
|
||||
border =
|
||||
BorderStroke(
|
||||
if (taken) 2.dp else 1.dp,
|
||||
if (taken) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
colors =
|
||||
ButtonDefaults.outlinedButtonColors(
|
||||
disabledContentColor =
|
||||
if (taken) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
),
|
||||
) {
|
||||
Text(option.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,16 @@ sealed class SessionEvent {
|
||||
data class MessageQueued(val id: String, val text: String, val images: List<String>) :
|
||||
SessionEvent()
|
||||
|
||||
/**
|
||||
* A queued message taken back before the session read it.
|
||||
*
|
||||
* Recorded by the server for the same reason [MessageQueued] is: a phone that reconnects
|
||||
* replays both, and without this one it would put back a bubble for a message that is never
|
||||
* coming -- with nothing left to resolve it, since the [UserMessage] that normally does is
|
||||
* exactly what was cancelled.
|
||||
*/
|
||||
data class MessageDropped(val id: String) : SessionEvent()
|
||||
|
||||
data class AssistantText(val delta: String) : SessionEvent()
|
||||
|
||||
data class ToolStart(val id: String, val tool: String, val input: String) : SessionEvent()
|
||||
@@ -183,6 +193,7 @@ fun parseSeqEvent(json: String): SeqEvent {
|
||||
body.getString("text"),
|
||||
body.stringList("images"),
|
||||
)
|
||||
"messageDropped" -> SessionEvent.MessageDropped(body.getString("id"))
|
||||
"assistantText" -> SessionEvent.AssistantText(body.getString("delta"))
|
||||
"toolStart" ->
|
||||
SessionEvent.ToolStart(
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
@@ -29,6 +35,9 @@ import androidx.compose.ui.unit.dp
|
||||
fun AssistantMessage(
|
||||
text: String,
|
||||
replies: ParsedReplies,
|
||||
/** Which notes are open, by [MessagePart.Remembered.text] -- see [MemoryNote]. */
|
||||
openNotes: Set<String>,
|
||||
onToggleNote: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
live: Boolean = false,
|
||||
) {
|
||||
@@ -43,7 +52,8 @@ fun AssistantMessage(
|
||||
parts.forEach { part ->
|
||||
when (part) {
|
||||
is MessagePart.Prose -> BlockedMarkdown(part.text, replies, live = live)
|
||||
is MessagePart.Remembered -> MemoryNote(part, replies)
|
||||
is MessagePart.Remembered ->
|
||||
MemoryNote(part, replies, part.text in openNotes) { onToggleNote(part.text) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,19 +77,53 @@ fun messageParts(text: String): List<MessagePart> {
|
||||
return if (parts.singleOrNull() is MessagePart.Prose) listOf(MessagePart.Prose(text)) else parts
|
||||
}
|
||||
|
||||
/**
|
||||
* One sentence the model attributed to a memory file, closed until somebody asks.
|
||||
*
|
||||
* Closed by default, like a tool call and a peer message and for the same reason: it is not part of
|
||||
* what was said to the reader, it is a note about where a claim came from. Left open it breaks the
|
||||
* reply in half around a card, which reads as the answer having stopped and restarted -- and these
|
||||
* arrive several to a message.
|
||||
*
|
||||
* What stays visible is which file it came from, because that is the whole of what the note claims
|
||||
* and it is the part a reader scanning for "why does it think that" is looking for.
|
||||
*
|
||||
* Open-ness is the screen's, keyed by the note's own text: a note opened and scrolled past has to
|
||||
* still be open on the way back, and a card that remembered for itself would forget the moment the
|
||||
* list stopped composing it. The text is a good enough name -- it does not change once the closing
|
||||
* tag has arrived, so a note stays open across the moment its reply settles.
|
||||
*/
|
||||
@Composable
|
||||
fun MemoryNote(note: MessagePart.Remembered, replies: ParsedReplies) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
fun MemoryNote(
|
||||
note: MessagePart.Remembered,
|
||||
replies: ParsedReplies,
|
||||
expanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
// Named, not just tinted: a colour can say "this one is different", but it cannot say
|
||||
// what kind of different, and "recalled from a file" is a difference in kind.
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
if (note.files.size == 1) "remembered from ${note.files[0]}"
|
||||
else "remembered from ${note.files.joinToString(", ")}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
MarkdownText(note.text, replies, Modifier.padding(top = 4.dp))
|
||||
if (!expanded) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
note.text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
// The head, not the tail: a sentence is identified by how it opens.
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (expanded) MarkdownText(note.text, replies, Modifier.padding(top = 4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,12 +78,18 @@ fun rememberSessionBitmap(settings: ServerSettings, sessionId: String, ref: Stri
|
||||
* is what it should be.
|
||||
*
|
||||
* Four lines of body text, so a screenshot reads as an attachment beside the conversation rather
|
||||
* than as a page of its own. Full size is one tap away.
|
||||
* than as a page of its own. Full size is one tap away -- but the full-size view itself is not
|
||||
* here. [onOpen] hands the ref to the screen, which draws [SessionImageViewer] outside the list;
|
||||
* see that function for the reason.
|
||||
*/
|
||||
@Composable
|
||||
fun SessionImage(settings: ServerSettings, sessionId: String, ref: String) {
|
||||
fun SessionImage(
|
||||
settings: ServerSettings,
|
||||
sessionId: String,
|
||||
ref: String,
|
||||
onOpen: (String) -> Unit,
|
||||
) {
|
||||
val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
|
||||
var full by remember(ref) { mutableStateOf(false) }
|
||||
val height = thumbnailHeight()
|
||||
val heightPx = with(LocalDensity.current) { height.roundToPx() }
|
||||
Box(Modifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) {
|
||||
@@ -102,12 +108,60 @@ fun SessionImage(settings: ServerSettings, sessionId: String, ref: String) {
|
||||
contentDescription = "Attached image, tap to view full screen",
|
||||
contentScale = ContentScale.Fit,
|
||||
filterQuality = enlargingFilter(image.height, heightPx),
|
||||
modifier = Modifier.fillMaxSize().clickable { full = true },
|
||||
modifier = Modifier.fillMaxSize().clickable { onOpen(ref) },
|
||||
alignment = Alignment.CenterStart,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (full) bitmap?.let { image -> ImageViewer(image) { full = false } }
|
||||
}
|
||||
|
||||
/**
|
||||
* The image somebody opened, drawn by the screen rather than by the row it was tapped in.
|
||||
*
|
||||
* The row is the wrong place to hold this, and it took a real fault to see why: an image from a
|
||||
* `Read` on its own is a row of one call, and the moment the next call arrives the two become a
|
||||
* group -- a different composable in a different part of the tree, so everything the old subtree
|
||||
* remembered goes, the dialog included. Somebody looking at a screenshot was thrown back to the
|
||||
* transcript because the session made another tool call. The same happens to a row regrouped by a
|
||||
* page of history landing.
|
||||
*
|
||||
* Held by the screen, none of that reaches it: what is open is a property of the screen, not of
|
||||
* whichever row happened to draw the thumbnail.
|
||||
*
|
||||
* The cost is one fetch, since the thumbnail's decoded bitmap belongs to a row this does not go
|
||||
* through. Paid deliberately rather than plumbed around: it is one request for a picture somebody
|
||||
* asked to see, and the loading and unavailable states below are the same two the thumbnail draws.
|
||||
*/
|
||||
@Composable
|
||||
fun SessionImageViewer(
|
||||
settings: ServerSettings,
|
||||
sessionId: String,
|
||||
ref: String,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
|
||||
Dialog(
|
||||
onDismissRequest = onClose,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(Color.Black).clickable(onClick = onClose),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
when (val image = bitmap) {
|
||||
// Two states, not one, exactly as the thumbnail has them: still coming, and never
|
||||
// coming. Stated in white because this box paints its own black behind them and a
|
||||
// theme colour would be picked against a surface that is not there.
|
||||
null ->
|
||||
Text(
|
||||
if (failed) "Image $ref is unavailable" else "Loading image…",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
else -> ZoomableImage(image)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,23 +193,23 @@ private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality
|
||||
/**
|
||||
* The image on its own, as large as it fits, with pinch to zoom.
|
||||
*
|
||||
* A dialog rather than a screen, so the platform's back gesture returns to the transcript instead
|
||||
* of leaving the app. It opens fitted -- the whole image visible, which is the thing a reader wants
|
||||
* first -- and zoom is theirs from there.
|
||||
* Inside a dialog rather than a screen -- see [SessionImageViewer] -- so the platform's back
|
||||
* gesture returns to the transcript instead of leaving the app. It opens fitted, the whole image
|
||||
* visible, which is the thing a reader wants first; zoom is theirs from there.
|
||||
*/
|
||||
@Composable
|
||||
private fun ImageViewer(image: ImageBitmap, onClose: () -> Unit) {
|
||||
Dialog(
|
||||
onDismissRequest = onClose,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
private fun ZoomableImage(image: ImageBitmap) {
|
||||
var scale by remember { mutableFloatStateOf(1f) }
|
||||
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||
var offsetY by remember { mutableFloatStateOf(0f) }
|
||||
Box(
|
||||
Image(
|
||||
bitmap = image,
|
||||
contentDescription = "Attached image",
|
||||
contentScale = ContentScale.Fit,
|
||||
// Zoomed in, the reader is looking at pixels on purpose.
|
||||
filterQuality = FilterQuality.None,
|
||||
modifier =
|
||||
Modifier.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.clickable(onClick = onClose)
|
||||
.pointerInput(Unit) {
|
||||
detectTransformGestures { _, pan, zoom, _ ->
|
||||
// Floor of 1 so the image cannot be pinched smaller than fitted, which is
|
||||
@@ -169,23 +223,12 @@ private fun ImageViewer(image: ImageBitmap, onClose: () -> Unit) {
|
||||
offsetY = 0f
|
||||
}
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
bitmap = image,
|
||||
contentDescription = "Attached image",
|
||||
contentScale = ContentScale.Fit,
|
||||
// Zoomed in, the reader is looking at pixels on purpose.
|
||||
filterQuality = FilterQuality.None,
|
||||
modifier =
|
||||
Modifier.fillMaxSize().graphicsLayer {
|
||||
}
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
translationX = offsetX
|
||||
translationY = offsetY
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -245,6 +246,9 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
// A model the reader has chosen and not yet confirmed. See [ModelSwitchWarning]: switching
|
||||
// makes the session re-read the whole conversation, which is worth asking about first.
|
||||
var pendingModel by remember { mutableStateOf<String?>(null) }
|
||||
// What was last taken from the command suggestions, so the list closes behind it; see
|
||||
// [CommandSuggestions] at its call site.
|
||||
var picked by remember { mutableStateOf<String?>(null) }
|
||||
var expandedTools by remember { mutableStateOf(setOf<String>()) }
|
||||
// Which runs of adjacent tool calls are open. Keyed by the first call's
|
||||
// id, so a group survives more calls arriving after it.
|
||||
@@ -256,6 +260,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
// by default, which is the rule for anything new in this transcript: a screen that opens
|
||||
// everything it can is one nobody can scan.
|
||||
var expandedNotes by remember { mutableStateOf(setOf<Long>()) }
|
||||
// Which memory notes are open, by the note's own text -- see [MemoryNote]. Closed by default
|
||||
// like everything else new in this transcript, and held here rather than in the card so a
|
||||
// note opened and scrolled past is still open on the way back.
|
||||
var openMemories by remember { mutableStateOf(setOf<String>()) }
|
||||
// The image being looked at full screen, by ref. Here rather than in the row that drew the
|
||||
// thumbnail: a row regrouped underneath the reader takes its whole subtree with it, and the
|
||||
// dialog with it -- see [SessionImageViewer].
|
||||
var fullImage by remember { mutableStateOf<String?>(null) }
|
||||
// Uploaded-but-not-yet-sent attachment ids; sent with the next message.
|
||||
var pendingAttachments by remember { mutableStateOf(listOf<String>()) }
|
||||
// What this session is set to now, seeded from the row that opened it and
|
||||
@@ -371,6 +383,11 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
if (event is SessionEvent.UserMessage) {
|
||||
queued = queued.filterNot { it.id == event.id }
|
||||
}
|
||||
// Waiting, then taken back. From the server rather than from the tap, so every device
|
||||
// drops the bubble and a reconnect does not put back one that was cancelled.
|
||||
if (event is SessionEvent.MessageDropped) {
|
||||
queued = queued.filterNot { it.id == event.id }
|
||||
}
|
||||
// Waiting, then gone: a command leaves this list when the session takes it,
|
||||
// and the row it becomes is added by `foldEvent` in the same pass.
|
||||
if (event is SessionEvent.CommandQueued) {
|
||||
@@ -486,6 +503,24 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
* value, which does not change under a running one.
|
||||
*/
|
||||
suspend fun loadOlderPage(limit: Int = HISTORY_PAGE): Boolean {
|
||||
// Nothing is loaded, so there is no "before" to ask about, and asking anyway is not a
|
||||
// harmless no-op: `before = 0` fetches the events before the first one, which is none,
|
||||
// and an empty page is how this function is told it has reached the start of the
|
||||
// conversation -- so it would latch `moreHistory` false and the session could never be
|
||||
// paged back at all.
|
||||
//
|
||||
// The window it fires in is the first layout. `moreHistory` starts true, which puts the
|
||||
// history spinner in the list, which makes `visibleItemsInfo` non-empty before a single
|
||||
// event has arrived -- and with no units loaded the room ahead adds up to zero, so the
|
||||
// pager fetches. On a loopback server the opening page beat it and nothing was ever
|
||||
// wrong; at `--delay 150`, which is what a phone over the tunnel actually costs, it won
|
||||
// the race and the transcript stopped one page from its newest end with no spinner and
|
||||
// nothing to say why.
|
||||
//
|
||||
// Guarded here rather than at the two callers because it is a fact about the question,
|
||||
// not about who is asking: the post-open fetch reaches it too, on the path where the
|
||||
// opening page failed and left `oldestSeq` unset.
|
||||
if (oldestSeq == 0L) return false
|
||||
// The fetch *and* the fold, both off the thread that draws. Only the fetch used to be,
|
||||
// and the fold is the expensive half: `foldEvent` returns a new list per event, so a page
|
||||
// of [HISTORY_PAGE] events is that many copies of a list growing to that length -- around
|
||||
@@ -890,6 +925,43 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the server to take back a message the session has not read yet.
|
||||
*
|
||||
* Nothing is removed here. The bubble goes on the `messageDropped` the server records, which is
|
||||
* what makes the cancellation the session's own fact rather than this screen's opinion of it --
|
||||
* a second device watching the same session has to lose the bubble too, and this one has to
|
||||
* still lose it after a reconnect.
|
||||
*
|
||||
* The refusal is kept on the message it was about rather than in [actionError]: the error row
|
||||
* lives under the header, and a bubble at the foot of the transcript is the thing that was
|
||||
* pressed. It is the ordinary answer here rather than the exceptional one -- a Claude session
|
||||
* writes a steer into the CLI the moment it arrives, so what is on screen as "waiting" is
|
||||
* waiting to be *read*, not waiting to be sent.
|
||||
*/
|
||||
fun takeBack(messageId: String) {
|
||||
scope.launch {
|
||||
val refusal =
|
||||
try {
|
||||
withContext(Dispatchers.IO) { unqueueMessage(settings, summary.id, messageId) }
|
||||
null
|
||||
} catch (e: ApiException) {
|
||||
e.message ?: "this message could not be taken back"
|
||||
}
|
||||
queued = queued.map { if (it.id == messageId) it.copy(refusal = refusal) else it }
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens one image full screen, from whichever row drew it; see [SessionImageViewer]. */
|
||||
fun openImage(ref: String) {
|
||||
fullImage = ref
|
||||
}
|
||||
|
||||
/** Opens or closes one memory note, wherever it is drawn; see [MemoryNote]. */
|
||||
fun toggleMemory(text: String) {
|
||||
openMemories = if (text in openMemories) openMemories - text else openMemories + text
|
||||
}
|
||||
|
||||
fun act(onDone: () -> Unit = {}, action: () -> Unit) {
|
||||
scope.launch {
|
||||
try {
|
||||
@@ -1160,7 +1232,15 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
sessionId = summary.id,
|
||||
text = waiting.text,
|
||||
images = waiting.images,
|
||||
onOpenImage = ::openImage,
|
||||
pending = true,
|
||||
refusal = waiting.refusal,
|
||||
// The bubble goes away on the `messageDropped` this
|
||||
// produces, not here: the server is what knows whether
|
||||
// the message was still its to take back, and the
|
||||
// other devices watching this session have to be told
|
||||
// by the same event.
|
||||
onTakeBack = { takeBack(waiting.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1169,7 +1249,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
) { unit ->
|
||||
when (unit) {
|
||||
is TranscriptUnit.Block -> MarkdownText(unit.text, replies)
|
||||
is TranscriptUnit.Memory -> MemoryNote(unit.part, replies)
|
||||
is TranscriptUnit.Memory ->
|
||||
MemoryNote(
|
||||
unit.part,
|
||||
replies,
|
||||
unit.part.text in openMemories,
|
||||
) {
|
||||
toggleMemory(unit.part.text)
|
||||
}
|
||||
is TranscriptUnit.Whole -> {
|
||||
val row = unit.row
|
||||
Box(
|
||||
@@ -1256,7 +1343,12 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
}
|
||||
},
|
||||
image = { ref ->
|
||||
SessionImage(settings, summary.id, ref)
|
||||
SessionImage(
|
||||
settings,
|
||||
summary.id,
|
||||
ref,
|
||||
::openImage,
|
||||
)
|
||||
},
|
||||
)
|
||||
is TranscriptRow.Single ->
|
||||
@@ -1267,6 +1359,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
sessionId = summary.id,
|
||||
text = item.text,
|
||||
images = item.images,
|
||||
onOpenImage = ::openImage,
|
||||
)
|
||||
is TranscriptItem.AssistantMsg ->
|
||||
// A whole assistant row is only ever the reply
|
||||
@@ -1282,6 +1375,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
AssistantMessage(
|
||||
item.text,
|
||||
replies,
|
||||
openNotes = openMemories,
|
||||
onToggleNote = ::toggleMemory,
|
||||
live = true,
|
||||
)
|
||||
is TranscriptItem.ToolRun ->
|
||||
@@ -1307,7 +1402,12 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
}
|
||||
},
|
||||
image = { ref ->
|
||||
SessionImage(settings, summary.id, ref)
|
||||
SessionImage(
|
||||
settings,
|
||||
summary.id,
|
||||
ref,
|
||||
::openImage,
|
||||
)
|
||||
},
|
||||
)
|
||||
is TranscriptItem.QuestionCard ->
|
||||
@@ -1328,7 +1428,12 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
is TranscriptItem.ImageItem ->
|
||||
SessionImage(settings, summary.id, item.ref)
|
||||
SessionImage(
|
||||
settings,
|
||||
summary.id,
|
||||
item.ref,
|
||||
::openImage,
|
||||
)
|
||||
is TranscriptItem.Note ->
|
||||
Text(
|
||||
item.text,
|
||||
@@ -1465,8 +1570,17 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
// Between the transcript and the box: above what is being typed, so the list does not
|
||||
// cover the thing the command is about, and below everything that explains it.
|
||||
CommandSuggestions(
|
||||
commands = suggestedCommands(input),
|
||||
onPick = { command -> input = command.typed() },
|
||||
// Nothing to suggest about a suggestion that was just taken. `/compact` is a
|
||||
// whole command *and* a prefix of itself, so picking it left the list standing
|
||||
// there with the one row already chosen -- the reader has to dismiss a list that
|
||||
// has nothing left to offer, in front of the box they are about to send from.
|
||||
// Held by what was picked rather than by a flag, so typing anything else brings
|
||||
// the list back without needing a second thing to reset.
|
||||
commands = if (input == picked) emptyList() else suggestedCommands(input),
|
||||
onPick = { command ->
|
||||
input = command.typed()
|
||||
picked = command.typed()
|
||||
},
|
||||
)
|
||||
|
||||
// Always enabled -- a send while the session is running becomes a
|
||||
@@ -1539,7 +1653,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
// see [ModelSwitchWarning].
|
||||
onPick = { chosen ->
|
||||
if (
|
||||
modelLabel(chosen) == modelLabel(model) || items.isEmpty()
|
||||
modelLabel(chosen) == modelLabel(model) ||
|
||||
!worthWarningAbout(status, contextTokens, items)
|
||||
) {
|
||||
act { setSessionModel(settings, summary.id, chosen) }
|
||||
} else {
|
||||
@@ -1618,6 +1733,9 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
}
|
||||
}
|
||||
|
||||
// Beside the other two dialogs, and outside the list for the same reason as them: what is
|
||||
// open is the screen's business rather than any row's. See [SessionImageViewer].
|
||||
fullImage?.let { ref -> SessionImageViewer(settings, summary.id, ref) { fullImage = null } }
|
||||
if (usageOpen) {
|
||||
UsageDialog(settings = settings, onDismiss = { usageOpen = false })
|
||||
}
|
||||
@@ -1679,6 +1797,11 @@ private fun ProcessAction.perform(settings: ServerSettings, sessionId: String) =
|
||||
*
|
||||
* [pending] is one the server has taken and the session has not read yet -- drawn quieter, because
|
||||
* "said" and "heard" are different claims and the transcript must not merge them.
|
||||
*
|
||||
* A pending bubble is tappable: [onTakeBack] asks the server to drop the message before the session
|
||||
* reads it, and [refusal] is what came back when it would not. The refusal is drawn here rather
|
||||
* than with the screen's other errors because this is where the reader pressed -- the error row is
|
||||
* under the header, a screen away from the bubble they were looking at.
|
||||
*/
|
||||
@Composable
|
||||
private fun UserBubble(
|
||||
@@ -1686,7 +1809,10 @@ private fun UserBubble(
|
||||
sessionId: String,
|
||||
text: String,
|
||||
images: List<String> = emptyList(),
|
||||
onOpenImage: (String) -> Unit,
|
||||
pending: Boolean = false,
|
||||
refusal: String? = null,
|
||||
onTakeBack: (() -> Unit)? = null,
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
Card(
|
||||
@@ -1700,7 +1826,19 @@ private fun UserBubble(
|
||||
if (pending) MaterialTheme.colorScheme.surfaceVariant
|
||||
else MaterialTheme.colorScheme.primaryContainer
|
||||
),
|
||||
modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp),
|
||||
modifier =
|
||||
Modifier.align(Alignment.CenterEnd)
|
||||
.padding(start = 48.dp)
|
||||
.then(
|
||||
if (onTakeBack == null) Modifier
|
||||
else
|
||||
Modifier.clickable(onClick = onTakeBack).semantics {
|
||||
// The bubble is its own control and its own label; without this
|
||||
// the only thing to read is the message, which does not say what
|
||||
// pressing it does.
|
||||
contentDescription = "Waiting to be read; tap to take it back"
|
||||
}
|
||||
),
|
||||
) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
// A message can be nothing but an attachment, and an empty line above a picture
|
||||
@@ -1718,15 +1856,60 @@ private fun UserBubble(
|
||||
// same place down the transcript, whether or not there is an image in it.
|
||||
images.forEachIndexed { index, ref ->
|
||||
if (index > 0 || text.isNotEmpty()) Spacer(Modifier.height(4.dp))
|
||||
SessionImage(settings, sessionId, ref)
|
||||
SessionImage(settings, sessionId, ref, onOpenImage)
|
||||
}
|
||||
refusal?.let {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A message the server has accepted and the session has not read yet. */
|
||||
private data class QueuedMessage(val id: String, val text: String, val images: List<String>)
|
||||
/**
|
||||
* A message the server has accepted and the session has not read yet.
|
||||
*
|
||||
* [refusal] is why taking it back did not work, kept per message rather than on the screen: two
|
||||
* bubbles can be waiting at once, and an error above them both would not say which one it was
|
||||
* about.
|
||||
*/
|
||||
private data class QueuedMessage(
|
||||
val id: String,
|
||||
val text: String,
|
||||
val images: List<String>,
|
||||
val refusal: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether a model switch has anything to warn about -- see [ModelSwitchWarning].
|
||||
*
|
||||
* What the warning is about is a *cache* being dropped, so the question is whether there is one.
|
||||
* Two answers say there is not, and both used to produce the dialog anyway:
|
||||
*
|
||||
* A session whose process has exited has nothing running to hold a cache, so the next turn was
|
||||
* always going to re-read the conversation -- the switch adds nothing to that bill. And a session
|
||||
* reporting zero context is holding nothing, which is what `/clear` leaves behind.
|
||||
*
|
||||
* Where the figure is *unknown* rather than zero the fallback is what it always was: whether
|
||||
* anything has been said at all. Unknown is not nothing, and treating it as nothing would drop the
|
||||
* warning on exactly the sessions -- an import, a fresh reattach -- where nobody has measured yet
|
||||
* and the conversation may be enormous.
|
||||
*/
|
||||
private fun worthWarningAbout(
|
||||
status: String,
|
||||
contextTokens: Long?,
|
||||
items: List<TranscriptItem>,
|
||||
): Boolean =
|
||||
when {
|
||||
status == "exited" -> false
|
||||
contextTokens != null -> contextTokens > 0
|
||||
else -> items.isNotEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Asked before switching model, because switching is not free and the cost is invisible.
|
||||
|
||||
@@ -68,16 +68,52 @@ fun SessionSettingsDialog(
|
||||
// it, which is what not knowing looks like: distinguishable from off, and from a refusal.
|
||||
var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) }
|
||||
var notifyError by remember { mutableStateOf<String?>(null) }
|
||||
// Where the session works. Null until the server has been asked, for the same reason the
|
||||
// switch above is: the row this dialog opened over is a snapshot, and a path drawn from it
|
||||
// could be one somebody changed from another device. An empty answer is a session that was
|
||||
// never given a directory, which is not the same as one whose directory is unknown -- the
|
||||
// field is only enabled once one of those two is settled.
|
||||
var cwd by remember(sessionId) { mutableStateOf<String?>(null) }
|
||||
var typedCwd by remember(sessionId) { mutableStateOf("") }
|
||||
var cwdError by remember { mutableStateOf<String?>(null) }
|
||||
var movingCwd by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(sessionId) {
|
||||
notify =
|
||||
try {
|
||||
withContext(Dispatchers.IO) { fetchSession(settings, sessionId).notify }
|
||||
val fresh = withContext(Dispatchers.IO) { fetchSession(settings, sessionId) }
|
||||
notify = fresh.notify
|
||||
cwd = fresh.cwd.orEmpty()
|
||||
typedCwd = fresh.cwd.orEmpty()
|
||||
} catch (e: ApiException) {
|
||||
// Left unknown rather than falling back to the stale row: the switch stays
|
||||
// disabled, instead of offering a position nothing confirmed.
|
||||
notifyError = e.message
|
||||
null
|
||||
notify = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the session, which ends the process that is in the old directory.
|
||||
*
|
||||
* Said plainly beside the field rather than confirmed in a second dialog: what it costs is a
|
||||
* process, and a stopped session is a state this app already has a word and a button for.
|
||||
*/
|
||||
fun moveCwd() {
|
||||
val chosen = typedCwd.trim()
|
||||
if (movingCwd || chosen.isEmpty() || chosen == cwd) return
|
||||
movingCwd = true
|
||||
cwdError = null
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) { setSessionCwd(settings, sessionId, chosen) }
|
||||
cwd = chosen
|
||||
} catch (e: ApiException) {
|
||||
// Where it happened: this field is the only thing on screen that knows a move was
|
||||
// asked for, and the reason is usually the path itself.
|
||||
cwdError = e.message
|
||||
} finally {
|
||||
movingCwd = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +203,53 @@ fun SessionSettingsDialog(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = typedCwd,
|
||||
onValueChange = { typedCwd = it },
|
||||
label = { Text("Working directory") },
|
||||
// What the field cannot say by being empty: a session that was never
|
||||
// given one starts wherever its launcher does, and this names that
|
||||
// rather than showing a path nobody chose.
|
||||
placeholder = { Text("wherever the session was started") },
|
||||
singleLine = true,
|
||||
enabled = cwd != null && !movingCwd,
|
||||
modifier = Modifier.weight(1f),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { moveCwd() }),
|
||||
)
|
||||
TextButton(
|
||||
onClick = { moveCwd() },
|
||||
enabled =
|
||||
cwd != null &&
|
||||
!movingCwd &&
|
||||
typedCwd.trim().isNotEmpty() &&
|
||||
typedCwd.trim() != cwd,
|
||||
) {
|
||||
Text(if (movingCwd) "Moving..." else "Move")
|
||||
}
|
||||
}
|
||||
// The whole of what pressing Move does, where it is about to be pressed. A
|
||||
// directory is settled when the process is spawned, so there is no changing one
|
||||
// under a running session -- it is ended, and the next thing said to the session
|
||||
// starts it in the new place.
|
||||
Text(
|
||||
"Moving stops the session's process. It starts again in the new directory " +
|
||||
"with the next message, or with Start.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
cwdError?.let {
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
error?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
|
||||
@@ -420,15 +420,11 @@ private fun PermissionAsk(ask: TranscriptItem.QuestionCard, onAnswer: (List<Stri
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = awaitingColor,
|
||||
)
|
||||
if (ask.answers.isNotEmpty()) {
|
||||
Text(
|
||||
"Answered: ${ask.answers.joinToString(", ")}",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
AnswerOptions(ask.options, onAnswer)
|
||||
}
|
||||
// Answered or not, the options stay and the one that was taken is marked -- see
|
||||
// [AskedQuestion], which is the same rule on the question card. A permission is where it
|
||||
// matters most: "Answered: Deny" alone does not say that Allow was the alternative, and
|
||||
// whether a tool was allowed or refused is the thing a reader comes back to this row for.
|
||||
AnswerOptions(ask.options, ask.answers, onAnswer.takeIf { ask.answers.isEmpty() })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -179,18 +179,24 @@ private fun runIdFor(items: List<TranscriptItem>, id: String, tool: String): Str
|
||||
* boundary destroys. The older row wins on what a start knows (the tool's name, its input) and the
|
||||
* newer on what an end knows (the output, and whether it finished), which is the only way round
|
||||
* that loses nothing.
|
||||
*
|
||||
* The third thing is the *run*, and it is the one that used to be missed. Every page ends up here,
|
||||
* but [adoptRun] only ran on the path where a split call had been found -- so the boundary that
|
||||
* falls cleanly between two finished calls, which is most of them, went straight to concatenation
|
||||
* and left the older page's calls under the run name they were folded with. On screen: one run of
|
||||
* tool calls drawn as two groups, with the seam wherever the reader happened to have paged. The two
|
||||
* early returns were an optimisation on a list the size of one page, and they were skipping work
|
||||
* rather than saving it.
|
||||
*/
|
||||
fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> {
|
||||
val (older, newer) = healSplitMessage(earlier, later)
|
||||
val startedEarlier =
|
||||
older.filterIsInstance<TranscriptItem.ToolRun>().mapTo(mutableSetOf()) { it.id }
|
||||
if (startedEarlier.isEmpty()) return older + newer
|
||||
val endedLater =
|
||||
newer
|
||||
.filterIsInstance<TranscriptItem.ToolRun>()
|
||||
.associateBy { it.id }
|
||||
.filterKeys { it in startedEarlier }
|
||||
if (endedLater.isEmpty()) return older + newer
|
||||
val healed = older.map { row ->
|
||||
val half = (row as? TranscriptItem.ToolRun)?.let { endedLater[it.id] }
|
||||
if (row is TranscriptItem.ToolRun && half != null) {
|
||||
@@ -363,6 +369,9 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
||||
// No row of its own: a message that is still waiting is drawn as a pending bubble below
|
||||
// the transcript, and becomes an ordinary one where the session read it.
|
||||
is SessionEvent.MessageQueued -> items
|
||||
// The bubble goes away and nothing takes its place: the message was never read, so there
|
||||
// is nothing it belongs above.
|
||||
is SessionEvent.MessageDropped -> items
|
||||
is SessionEvent.Settings -> items
|
||||
is SessionEvent.Status -> items
|
||||
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -35,6 +36,14 @@ import androidx.compose.ui.unit.dp
|
||||
* What keeps a unit's arrival cheap enough to happen mid-fling: a unit is at most one block of a
|
||||
* reply, and its parse is already made by [warm] before the fold that introduces it -- so entering
|
||||
* composition costs laying out one paragraph, not parsing a message.
|
||||
*
|
||||
* The whole list sits in a [SelectionContainer], which is what makes every word in the transcript
|
||||
* selectable by the platform's own press-and-hold. Here rather than at each place text is drawn: a
|
||||
* transcript is one body of text to a reader, and a container per row would mean a selection could
|
||||
* never cross from a reply into the tool output that follows it -- and would leave whatever was
|
||||
* drawn without one silently unselectable, which is a state nothing on screen reports. Rows keep
|
||||
* their tap handlers: selection is a long press, and the container passes an ordinary click through
|
||||
* to the card under it.
|
||||
*/
|
||||
@Composable
|
||||
fun TranscriptList(
|
||||
@@ -45,19 +54,24 @@ fun TranscriptList(
|
||||
below: @Composable () -> Unit,
|
||||
unit: @Composable (TranscriptUnit) -> Unit,
|
||||
) {
|
||||
SelectionContainer {
|
||||
LazyColumn(
|
||||
state = state,
|
||||
reverseLayout = true,
|
||||
contentPadding = TRANSCRIPT_PADDING,
|
||||
modifier =
|
||||
// Timed in two halves because the frame's draw phase is where Compose's measurement
|
||||
// lands, and "draw is high while nothing is being recorded" does not say which half;
|
||||
// lands, and "draw is high while nothing is being recorded" does not say which
|
||||
// half;
|
||||
// see [drawAccounting]. Measure includes composing the items that scrolled in.
|
||||
modifier
|
||||
.layout { measurable, constraints ->
|
||||
val started = System.nanoTime()
|
||||
val placeable = measurable.measure(constraints)
|
||||
DebugStats.record("measure: the whole transcript", System.nanoTime() - started)
|
||||
DebugStats.record(
|
||||
"measure: the whole transcript",
|
||||
System.nanoTime() - started,
|
||||
)
|
||||
layout(placeable.width, placeable.height) {
|
||||
val placing = System.nanoTime()
|
||||
placeable.place(0, 0)
|
||||
@@ -81,7 +95,8 @@ fun TranscriptList(
|
||||
Box(Modifier.fillMaxWidth().padding(top = u.gap)) { unit(u) }
|
||||
}
|
||||
// Standing in for everything not fetched yet. Only here while there is more -- its
|
||||
// appearance at the top edge is also roughly when the next page is asked for, so what it
|
||||
// appearance at the top edge is also roughly when the next page is asked for, so what
|
||||
// it
|
||||
// reports is a fetch in flight rather than an end reached.
|
||||
if (moreHistory) {
|
||||
item(key = "history", contentType = "history") {
|
||||
@@ -93,6 +108,7 @@ fun TranscriptList(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The gap between rows, and the room around the whole conversation. */
|
||||
|
||||
+108
-1
@@ -17,11 +17,15 @@
|
||||
//! `reset` frame plus the newest window)
|
||||
//! POST /sessions/{id}/message {text, attachmentIds?}
|
||||
//! (starts the process first if it has exited)
|
||||
//! POST /sessions/{id}/unqueue {messageId} -- take back one not read yet
|
||||
//! (409 when the session already has it)
|
||||
//! POST /sessions/{id}/answer {questionId, answers} (questions and permissions)
|
||||
//! POST /sessions/{id}/interrupt stop the running turn; the process stays
|
||||
//! POST /sessions/{id}/stop end the process; the session and transcript stay
|
||||
//! POST /sessions/{id}/start run the process again, continuing the conversation
|
||||
//! POST /sessions/{id}/title {title}
|
||||
//! POST /sessions/{id}/cwd {cwd} -- move it; stops the process,
|
||||
//! which starts again in the new one
|
||||
//! POST /sessions/{id}/model {model}
|
||||
//! POST /sessions/{id}/command {text} -- /compact, /clear, /rename x, or the dialect's own
|
||||
//! (starts the process first if it has exited)
|
||||
@@ -68,7 +72,7 @@ use tokio::sync::{broadcast, mpsc};
|
||||
use tokio_stream::StreamExt;
|
||||
use tokio_stream::wrappers::{BroadcastStream, ReceiverStream};
|
||||
|
||||
use crate::session::driver::SessionCommand;
|
||||
use crate::session::driver::{SessionCommand, Unqueued};
|
||||
use crate::session::pending::Operation;
|
||||
use crate::session::transcript::{CATCH_UP_LIMIT, CatchUp, SeqEvent, catch_up};
|
||||
use crate::session::{LiveSession, SessionInfo, SessionManager, SpawnSpec};
|
||||
@@ -93,11 +97,13 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
|
||||
.route("/sessions/{id}/events", get(events))
|
||||
.route("/sessions/{id}/transcript", get(transcript))
|
||||
.route("/sessions/{id}/message", post(message))
|
||||
.route("/sessions/{id}/unqueue", post(unqueue))
|
||||
.route("/sessions/{id}/answer", post(answer))
|
||||
.route("/sessions/{id}/interrupt", post(interrupt))
|
||||
.route("/sessions/{id}/stop", post(stop))
|
||||
.route("/sessions/{id}/start", post(start))
|
||||
.route("/sessions/{id}/title", post(rename))
|
||||
.route("/sessions/{id}/cwd", post(set_cwd))
|
||||
.route("/sessions/{id}/model", post(set_model))
|
||||
.route("/sessions/{id}/permission-mode", post(set_permission_mode))
|
||||
.route("/sessions/{id}/notify", post(set_notify))
|
||||
@@ -123,6 +129,10 @@ enum ApiError {
|
||||
UnknownRoute,
|
||||
#[error("{0}")]
|
||||
BadRequest(String),
|
||||
/// The request was understood and the state it names has moved on --
|
||||
/// distinct from `BadRequest`, which is a caller that got it wrong.
|
||||
#[error("{0}")]
|
||||
Conflict(String),
|
||||
#[error(transparent)]
|
||||
Internal(#[from] anyhow::Error),
|
||||
}
|
||||
@@ -132,6 +142,7 @@ impl IntoResponse for ApiError {
|
||||
let status = match self {
|
||||
Self::NotFound(_) | Self::UnknownRoute => StatusCode::NOT_FOUND,
|
||||
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||
Self::Conflict(_) => StatusCode::CONFLICT,
|
||||
Self::Internal(err) => {
|
||||
// The only variant whose real cause isn't safe to hand
|
||||
// back verbatim, and the only one worth a log line.
|
||||
@@ -900,6 +911,40 @@ async fn message(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct UnqueueRequest {
|
||||
/// The id the `messageQueued` event carried, which is what the bubble
|
||||
/// on screen is drawn from.
|
||||
message_id: String,
|
||||
}
|
||||
|
||||
/// Takes back a message the session has not read yet.
|
||||
///
|
||||
/// The two failures are separate answers rather than one refusal, because
|
||||
/// they are different things to whoever tapped: `409` means the session has
|
||||
/// already been told and the message is on its way into the conversation,
|
||||
/// and `404` means nothing is waiting under that id -- a bubble on screen
|
||||
/// that something else has already resolved. See [`Driver::unqueue`]; the
|
||||
/// Claude driver can only ever give the first, since it writes a steer into
|
||||
/// the CLI the moment it arrives.
|
||||
async fn unqueue(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
axum::Json(body): axum::Json<UnqueueRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
match lookup(&manager, &id)?.unqueue(&body.message_id) {
|
||||
Unqueued::Dropped => Ok(StatusCode::NO_CONTENT),
|
||||
Unqueued::AlreadySent => Err(ApiError::Conflict(
|
||||
"the session has already been given this message".to_string(),
|
||||
)),
|
||||
Unqueued::Unknown => Err(ApiError::NotFound(
|
||||
"this message is not waiting to be read".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -1012,6 +1057,68 @@ async fn rename(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CwdRequest {
|
||||
cwd: PathBuf,
|
||||
}
|
||||
|
||||
/// Moves a session to a different working directory.
|
||||
///
|
||||
/// The directory is checked here rather than in the manager because
|
||||
/// checking it is an ssh round trip on a remote setup, and the manager is
|
||||
/// not async -- the same division `POST /sessions` already makes for the
|
||||
/// directory an import was recorded in.
|
||||
///
|
||||
/// Checked rather than trusted, and refused rather than corrected: a
|
||||
/// mistyped path that was accepted would leave a session recorded somewhere
|
||||
/// its process cannot start, and the failure would arrive later, as a
|
||||
/// session that would not come back, with nothing pointing at the typo. The
|
||||
/// spawn path corrects instead because it is resuming a directory the
|
||||
/// *machine* recorded, which can be gone through nobody's fault; a path
|
||||
/// somebody has just typed is different.
|
||||
///
|
||||
/// Note what this does not do: it does not start a replacement process.
|
||||
/// See [`SessionManager::set_session_cwd`].
|
||||
async fn set_cwd(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
axum::Json(body): axum::Json<CwdRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let session = manager
|
||||
.sessions()
|
||||
.into_iter()
|
||||
.find(|session| session.id == id)
|
||||
.ok_or_else(|| ApiError::NotFound(format!("no session {id}")))?;
|
||||
let cwd = body.cwd.to_string_lossy().trim().to_string();
|
||||
if cwd.is_empty() {
|
||||
return Err(ApiError::BadRequest(
|
||||
"a working directory is a path, and this one is empty".to_string(),
|
||||
));
|
||||
}
|
||||
// Absolute, because the alternative is relative to whatever the CLI is
|
||||
// launched from, which is not something the person typing it can see.
|
||||
if !cwd.starts_with('/') && !cwd.starts_with('~') {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"{cwd} is not an absolute path, so where it would be depends on where the \
|
||||
session happens to start"
|
||||
)));
|
||||
}
|
||||
let setup = setup_by_id(&manager, &session.setup)?;
|
||||
let transport = crate::session::transport::Transport::for_setup(&setup);
|
||||
if !crate::session::import::directory_exists(&transport, &cwd).await {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"{} has no directory {cwd}",
|
||||
setup.name
|
||||
)));
|
||||
}
|
||||
manager
|
||||
.set_session_cwd(&id, PathBuf::from(&cwd))
|
||||
.map_err(bad_request)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ModelRequest {
|
||||
|
||||
@@ -57,7 +57,7 @@ use serde_json::{Value, json};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use super::driver::{Driver, Event, EventSink, ImageRef, SessionStatus};
|
||||
use super::driver::{Driver, Event, EventSink, ImageRef, SessionStatus, Unqueued};
|
||||
use super::process;
|
||||
use super::transport::{Launch, Streams, Transport};
|
||||
use crate::config::{ProviderConfig, SessionConfig};
|
||||
@@ -150,10 +150,22 @@ impl Queue {
|
||||
/// Reported rather than dropped. These are messages somebody typed
|
||||
/// that never reached the session and never reached the transcript, so
|
||||
/// this is the only place they can be mentioned at all.
|
||||
///
|
||||
/// Each one is also *resolved*, with the same `MessageDropped` that a
|
||||
/// phone tapping the bubble produces. Without it the bubble sat there
|
||||
/// for good: a message drawn as waiting to be read, by a session that
|
||||
/// no longer exists, with the only thing that ever clears it -- the
|
||||
/// `UserMessage` -- exactly what is not coming. The error says what
|
||||
/// happened and the drop is what ends it, which is the same division
|
||||
/// of labour as everywhere else here.
|
||||
fn close(&mut self, sink: &EventSink, why: &str) {
|
||||
self.closed = true;
|
||||
self.running = false;
|
||||
let lost: Vec<String> = self.awaiting.drain(..).map(|(_, text, _)| text).collect();
|
||||
let lost: Vec<(String, String)> = self
|
||||
.awaiting
|
||||
.drain(..)
|
||||
.map(|(id, text, _)| (id, text))
|
||||
.collect();
|
||||
if lost.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -165,9 +177,15 @@ impl Queue {
|
||||
} else {
|
||||
format!("{} queued messages", lost.len())
|
||||
},
|
||||
lost.join(" / ")
|
||||
lost.iter()
|
||||
.map(|(_, text)| text.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ")
|
||||
),
|
||||
});
|
||||
for (id, _) in lost {
|
||||
let _ = sink.send(Event::MessageDropped { id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -595,6 +613,24 @@ impl Driver for ClaudeDriver {
|
||||
self.send_line(line);
|
||||
}
|
||||
|
||||
/// Never droppable, and that is a property of the design rather than
|
||||
/// an omission.
|
||||
///
|
||||
/// A message queued here has already been written to the CLI's stdin
|
||||
/// -- see [`Queue`], where only the *announcement* waits -- because
|
||||
/// that is what makes a steer reach the model at the next tool
|
||||
/// boundary instead of at the end of the turn. A line in the fifo
|
||||
/// cannot be recalled, so the only honest answers are "the session has
|
||||
/// already been told" and "nothing is waiting under that id".
|
||||
fn unqueue(&self, id: &str) -> Unqueued {
|
||||
let queue = self.queue.lock().unwrap();
|
||||
if queue.awaiting.iter().any(|(waiting, ..)| waiting == id) {
|
||||
Unqueued::AlreadySent
|
||||
} else {
|
||||
Unqueued::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
fn answer_question(&self, id: &str, answers: &[String]) {
|
||||
let response = {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
|
||||
@@ -221,6 +221,33 @@ impl Translator {
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let mut events = Vec::new();
|
||||
// A turn another agent started, which is only knowable here.
|
||||
//
|
||||
// Measured against CLI 2.1.237 (2026-08-31) by sending a
|
||||
// real cross-session message to a real stream-json session:
|
||||
// the CLI emits no `user` record for it, and nothing in the
|
||||
// partial-message stream mentions it either. The whole of
|
||||
// it arrives as an `origin` object on the turn's `result`,
|
||||
// in the same shape the session file records -- so this is
|
||||
// `import::peer_message` reading a different record.
|
||||
//
|
||||
// The cost is the position: the note lands after the reply
|
||||
// it caused rather than above it, because at no earlier
|
||||
// point in the turn does the CLI say why the turn started.
|
||||
// Taken deliberately over the alternative, which is a
|
||||
// second reader tailing the CLI's own session file for the
|
||||
// one record stdout does not carry -- two sources of truth
|
||||
// for one conversation, and a poll per live session. What
|
||||
// it buys is the thing that was missing entirely: a session
|
||||
// that starts working on something nobody on this phone
|
||||
// asked for is otherwise unexplainable from the phone.
|
||||
//
|
||||
// Only peer-caused turns carry it: measured over a real
|
||||
// session's stdout, four ordinary results and no `origin`
|
||||
// between them.
|
||||
if let Some(peer) = crate::session::import::peer_message(message) {
|
||||
events.push(peer);
|
||||
}
|
||||
// Whichever way this result went, the interrupt it may have
|
||||
// been answering is now spent.
|
||||
let asked_to_stop = std::mem::take(&mut self.interrupting);
|
||||
@@ -1089,6 +1116,70 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A turn another agent started says so, on the record that carries it.
|
||||
///
|
||||
/// The line is the real shape, taken from a real cross-session message
|
||||
/// sent to a real stream-json session on CLI 2.1.237 (2026-08-31) --
|
||||
/// including the `from` socket path, which is deliberately *not* what a
|
||||
/// reader is shown: the sending session's `name` is what they recognise
|
||||
/// it by. The `body` is the message as it was written; the content the
|
||||
/// model is given beside it wraps the same text in a preamble and a
|
||||
/// `<cross-session-message>` tag, which is written for the model rather
|
||||
/// than for a person.
|
||||
///
|
||||
/// The note comes before the usage and the idle, so it sits as close to
|
||||
/// the turn it explains as the wire allows -- which is after the reply,
|
||||
/// not above it. See the comment at the callsite for why that is the
|
||||
/// best available position rather than an oversight.
|
||||
#[test]
|
||||
fn a_turn_started_by_another_agent_records_who_and_what() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let mut translator = Translator::new(dir.path().to_path_buf());
|
||||
let events = translate_lines(
|
||||
&mut translator,
|
||||
&[
|
||||
r#"{"type":"result","subtype":"success","is_error":false,"session_id":"s","usage":{"input_tokens":2,"output_tokens":5},"origin":{"kind":"peer","from":"uds:/run/user/1000/cc-socks/137108.sock","verifiedPeerPid":137108,"msg_id":"1e729740","name":"ai-app-2-fb","fromMode":"prompting","body":"Reply with just the word ACK."}}"#,
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![
|
||||
Event::PeerMessage {
|
||||
from: "ai-app-2-fb".to_string(),
|
||||
text: "Reply with just the word ACK.".to_string(),
|
||||
},
|
||||
Event::UsageDelta {
|
||||
tokens: 7,
|
||||
context: None
|
||||
},
|
||||
Event::Status {
|
||||
state: SessionStatus::Idle
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// And an ordinary turn does not, which is the half that decides
|
||||
/// whether the check above is a check or a rubber stamp. Measured over
|
||||
/// a real session's stdout: four results, no `origin` between them.
|
||||
#[test]
|
||||
fn an_ordinary_turn_carries_no_peer_note() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let mut translator = Translator::new(dir.path().to_path_buf());
|
||||
let events = translate_lines(
|
||||
&mut translator,
|
||||
&[
|
||||
r#"{"type":"result","subtype":"success","is_error":false,"session_id":"s","usage":{"input_tokens":2,"output_tokens":5}}"#,
|
||||
],
|
||||
);
|
||||
assert!(
|
||||
!events
|
||||
.iter()
|
||||
.any(|event| matches!(event, Event::PeerMessage { .. })),
|
||||
"a turn nobody else started must not be attributed to anyone: {events:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The context is the last assistant message's, not the result's.
|
||||
///
|
||||
/// Real figures from a two-message haiku turn on 2.1.237, captured
|
||||
|
||||
@@ -114,6 +114,22 @@ pub enum Event {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
images: Vec<ImageRef>,
|
||||
},
|
||||
/// A message taken out of the queue before the session read it, by
|
||||
/// somebody tapping the bubble that was waiting for it.
|
||||
///
|
||||
/// Recorded for the same reason `MessageQueued` is: the queue is the
|
||||
/// server's, so what is waiting has to be answerable from the
|
||||
/// transcript alone. Without it a phone that reconnects replays the
|
||||
/// `MessageQueued` and puts back a bubble for a message that will
|
||||
/// never arrive -- and nothing later would ever resolve it, since the
|
||||
/// `UserMessage` that normally does is exactly what is not coming.
|
||||
///
|
||||
/// Only ever sent for a message that had not been handed over. One
|
||||
/// that has is not droppable and says so instead; see
|
||||
/// [`Unqueued::AlreadySent`].
|
||||
MessageDropped {
|
||||
id: String,
|
||||
},
|
||||
/// A driver has taken one of the user's messages and started reading
|
||||
/// it. The manager turns this into the `UserMessage` above, so it
|
||||
/// never reaches a phone itself.
|
||||
@@ -444,6 +460,25 @@ pub enum SessionStatus {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// What became of a request to take a queued message back.
|
||||
///
|
||||
/// Three states rather than a bool because the two failures are not the
|
||||
/// same fact. A driver that writes into its session the moment a message
|
||||
/// arrives -- which is what `ClaudeDriver` does, so that a steer reaches
|
||||
/// the model at the next tool boundary rather than at the end of the turn
|
||||
/// -- can never take one back, and a phone that was told only "no" would
|
||||
/// have to guess whether it had asked too late or asked about nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Unqueued {
|
||||
/// Out of the queue; the session will never read it.
|
||||
Dropped,
|
||||
/// Already handed to the session, so there is nothing left to take
|
||||
/// back. The message is on its way into the conversation.
|
||||
AlreadySent,
|
||||
/// Nothing is waiting under that id.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Where a driver reports events. Unbounded because producers are child
|
||||
/// processes a slow phone must never be able to stall; the transcript file
|
||||
/// is the backpressure-free buffer of record.
|
||||
@@ -463,6 +498,19 @@ pub trait Driver: Send + Sync {
|
||||
/// message in the transcript, so a driver that never sends it drops
|
||||
/// the message from the conversation entirely.
|
||||
fn send_user_message(&self, text: String, images: Vec<ImageRef>);
|
||||
/// Takes back a message that is still waiting, named by the id its
|
||||
/// [`Event::MessageQueued`] carried.
|
||||
///
|
||||
/// Answering is the whole of the contract: a driver that drops the
|
||||
/// message owes an [`Event::MessageDropped`], and one that cannot must
|
||||
/// say which of the two reasons it is, because they are different
|
||||
/// things to a reader -- "the session has already been told" is worth
|
||||
/// knowing, and "there is nothing under that id" means the bubble on
|
||||
/// screen is stale. The default is the honest answer for a driver with
|
||||
/// no queue at all: nothing of yours is waiting.
|
||||
fn unqueue(&self, _id: &str) -> Unqueued {
|
||||
Unqueued::Unknown
|
||||
}
|
||||
/// Answers one question with everything that was chosen, in the order
|
||||
/// it was offered. One answer is a list of one; a driver whose dialect
|
||||
/// takes a single value joins them where it writes it.
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
//! looks like when a screen groups them. `gap` is seconds between one
|
||||
//! call and the next, default none: it is what makes a run *grow* while
|
||||
//! somebody is looking at it, which is the only way to reach the state
|
||||
//! where a call opened on its own gains a neighbour.
|
||||
//! where a call opened on its own gains a neighbour. The first call
|
||||
//! carries a screenshot, so that state can also be reached with an image
|
||||
//! open full screen -- which is where it used to close itself.
|
||||
//! - `/question [text]` -- a question, exercising the answer path.
|
||||
//! - `/ask` -- an AskUserQuestion call: two questions on one tool call,
|
||||
//! with descriptions, a preview and a multi-select, which is the shape
|
||||
@@ -53,7 +55,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::driver::{Driver, Event, EventSink, ImageRef, QuestionOption, SessionStatus};
|
||||
use super::driver::{Driver, Event, EventSink, ImageRef, QuestionOption, SessionStatus, Unqueued};
|
||||
|
||||
/// Delay between streamed deltas -- long enough that streaming is visibly
|
||||
/// streaming in the UI, short enough that tests waiting on a full turn
|
||||
@@ -482,6 +484,27 @@ impl EchoDriver {
|
||||
"timeout": 5000,
|
||||
}),
|
||||
});
|
||||
// The first call carries a screenshot, and only the
|
||||
// first. That is what makes this rig cover the case a
|
||||
// growing run is actually about: an image opened full
|
||||
// screen from a call that is alone, and then a second
|
||||
// call arriving and turning that row into a group. The
|
||||
// dialog used to be inside the row, so the reader was
|
||||
// thrown back to the transcript by the session making
|
||||
// another tool call. Any of the calls would do; the
|
||||
// first is the one that is on its own for a whole
|
||||
// `gap`, which is the window somebody can open it in.
|
||||
if i == 1 {
|
||||
let part = serde_json::json!({
|
||||
"source": {"media_type": "image/png", "data": SAMPLE_PNG}
|
||||
});
|
||||
if let Some(name) = super::claude::translate::save_image(&dir, &part) {
|
||||
send(Event::Image {
|
||||
image: name,
|
||||
about: Some(id.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(DELTA_DELAY).await;
|
||||
send(Event::ToolEnd {
|
||||
id,
|
||||
@@ -796,6 +819,23 @@ impl Driver for EchoDriver {
|
||||
!self.busy.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Really droppable, which is what makes this the rig for the phone's
|
||||
/// side of it: the held message is this driver's own and nothing has
|
||||
/// been written anywhere, so a tap here exercises the whole path
|
||||
/// through to the bubble disappearing on every device. The Claude
|
||||
/// driver can only ever refuse -- see its own `unqueue` -- so it
|
||||
/// cannot exercise the case where the drop succeeds.
|
||||
fn unqueue(&self, id: &str) -> Unqueued {
|
||||
let mut queued = self.queued.lock().unwrap();
|
||||
let Some(at) = queued.iter().position(|(waiting, ..)| waiting == id) else {
|
||||
return Unqueued::Unknown;
|
||||
};
|
||||
queued.remove(at);
|
||||
drop(queued);
|
||||
self.emit(Event::MessageDropped { id: id.to_string() });
|
||||
Unqueued::Dropped
|
||||
}
|
||||
|
||||
fn send_user_message(&self, text: String, images: Vec<ImageRef>) {
|
||||
// Announced, because this is a message: every driver owes exactly
|
||||
// one `MessageTaken` per message, and one that quietly vanishes
|
||||
|
||||
@@ -520,7 +520,7 @@ pub fn events_from(text: &str, session_dir: &std::path::Path) -> Vec<Event> {
|
||||
events
|
||||
}
|
||||
|
||||
/// A message from another agent, as the CLI records one.
|
||||
/// A message from another agent, as the CLI reports one.
|
||||
///
|
||||
/// Measured from a real session file (2026-08-29): the record is a `user`
|
||||
/// one marked `isMeta`, and its `origin` carries `kind: "peer"`, the
|
||||
@@ -529,7 +529,14 @@ pub fn events_from(text: &str, session_dir: &std::path::Path) -> Vec<Event> {
|
||||
/// preamble and a `<cross-session-message>` tag, which is written for the
|
||||
/// model that has to read it rather than for a person -- so the body is
|
||||
/// what a reader is shown, and the name is who they are told sent it.
|
||||
fn peer_message(record: &Value) -> Option<Event> {
|
||||
///
|
||||
/// Shared with the live driver (`claude::translate`), which finds the same
|
||||
/// `origin` object on a different record -- so this reads the object and
|
||||
/// not the record around it. One function because it is one wire format:
|
||||
/// two copies would drift the first time the CLI renames a field, and the
|
||||
/// half that drifted would go on producing nothing at all, which is
|
||||
/// indistinguishable from nobody having sent anything.
|
||||
pub(in crate::session) fn peer_message(record: &Value) -> Option<Event> {
|
||||
let origin = record.get("origin")?;
|
||||
if origin.get("kind").and_then(Value::as_str) != Some("peer") {
|
||||
return None;
|
||||
|
||||
+280
-21
@@ -32,7 +32,9 @@ use crate::config::{
|
||||
Config, DriverKind, ProviderConfig, SessionConfig, SetupConfig, SshConfig, TokenEntry,
|
||||
};
|
||||
use claude::ClaudeDriver;
|
||||
use driver::{Driver, Event, EventSink, ImageRef, SessionCommand, SessionStatus, context_after};
|
||||
use driver::{
|
||||
Driver, Event, EventSink, ImageRef, SessionCommand, SessionStatus, Unqueued, context_after,
|
||||
};
|
||||
use echo::EchoDriver;
|
||||
use llama::LlamaDriver;
|
||||
use transcript::{SeqEvent, Transcript};
|
||||
@@ -440,6 +442,21 @@ impl LiveSession {
|
||||
self.ask("be interrupted", |driver| driver.interrupt());
|
||||
}
|
||||
|
||||
/// Takes back a message the session has not read yet, named by the id
|
||||
/// its `MessageQueued` carried. See [`Driver::unqueue`] for why the
|
||||
/// answer has three states.
|
||||
///
|
||||
/// A session with no process answers `Unknown` rather than being
|
||||
/// reported as a failure, and that is the true answer: a driver on its
|
||||
/// way out already said what it was holding (`Queue::close`), so there
|
||||
/// is nothing waiting to take back.
|
||||
pub fn unqueue(&self, message_id: &str) -> Unqueued {
|
||||
match self.driver() {
|
||||
Some(driver) => driver.unqueue(message_id),
|
||||
None => Unqueued::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Leaves this session's process running and stops attending to it,
|
||||
/// for a server that is going away and means to come back. See
|
||||
/// [`Driver::detach`].
|
||||
@@ -483,15 +500,31 @@ impl LiveSession {
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// `setup_name` is passed in rather than stored: only the manager
|
||||
/// holds the config, and the label can change under a running session.
|
||||
/// `setup_name` and `cwd` are passed in rather than read from the
|
||||
/// snapshot this session launched with: only the manager holds the
|
||||
/// config, and both of them can change under a running session. The
|
||||
/// label changes when a setup is renamed; the directory changes when
|
||||
/// somebody moves the session, and reading the snapshot reported the
|
||||
/// old one for as long as the process lived -- a screen showing a
|
||||
/// directory the next launch will not use, with nothing saying so.
|
||||
///
|
||||
/// Passed rather than mirrored into `Shared`, which is where `title`
|
||||
/// and `notify` live: a second copy is a second thing to keep level,
|
||||
/// and this way there is one answer, read where the row is built.
|
||||
///
|
||||
/// `kind` rather than the facts derived from it: two of this row's
|
||||
/// fields are answers about the provider's *kind*, and passing them
|
||||
/// separately meant every caller deriving each one and a third arriving
|
||||
/// as a third parameter. `None` where the provider has been edited away,
|
||||
/// which is a session that cannot run -- so both answers are the
|
||||
/// cautious one rather than a guess.
|
||||
fn info(&self, setup_name: &str, imported: bool, kind: Option<DriverKind>) -> SessionInfo {
|
||||
fn info(
|
||||
&self,
|
||||
setup_name: &str,
|
||||
cwd: Option<&Path>,
|
||||
imported: bool,
|
||||
kind: Option<DriverKind>,
|
||||
) -> SessionInfo {
|
||||
SessionInfo {
|
||||
id: self.meta.id.clone(),
|
||||
provider: self.meta.provider.clone(),
|
||||
@@ -505,7 +538,7 @@ impl LiveSession {
|
||||
max_image_edge: kind.and_then(DriverKind::max_image_edge),
|
||||
imported,
|
||||
keeps_own_transcript: kind.is_some_and(DriverKind::keeps_own_transcript),
|
||||
cwd: self.meta.cwd.clone(),
|
||||
cwd: cwd.map(Path::to_path_buf),
|
||||
status: *self.shared.status.lock().unwrap(),
|
||||
last_activity: *self.shared.last_activity.lock().unwrap(),
|
||||
created: self.meta.created,
|
||||
@@ -945,6 +978,7 @@ impl SessionManager {
|
||||
.map(|meta| match inner.live.get(&meta.id) {
|
||||
Some(session) => session.info(
|
||||
label_of(&inner.config, &meta.setup),
|
||||
meta.cwd.as_deref(),
|
||||
import::read_cursor(&self.data_dir.join(&meta.id)).is_some(),
|
||||
kind_of(&inner.config, &meta.setup, &meta.provider),
|
||||
),
|
||||
@@ -1106,6 +1140,7 @@ impl SessionManager {
|
||||
// listing asks of the directory a moment later.
|
||||
let info = session.info(
|
||||
&setup.name,
|
||||
session.meta.cwd.as_deref(),
|
||||
import::read_cursor(&self.data_dir.join(&id)).is_some(),
|
||||
Some(provider.kind),
|
||||
);
|
||||
@@ -1268,6 +1303,60 @@ impl SessionManager {
|
||||
/// for every provider that has a process at all -- so asking it here
|
||||
/// stops a session whose driver is in no state to be asked, and adds no
|
||||
/// method a new driver could implement wrongly.
|
||||
/// Moves a session to a different working directory.
|
||||
///
|
||||
/// The directory is settled at spawn -- the CLI is launched with it as
|
||||
/// its cwd and there is no control request that changes one -- so this
|
||||
/// records the new one and ends the process that is in the old one. It
|
||||
/// does **not** start a replacement: a session with no process starts
|
||||
/// on the next thing said to it, or on Start, which is this app's one
|
||||
/// rule for that everywhere else. Starting one here would have to wait
|
||||
/// for the recorded status to catch up with a process that is already
|
||||
/// gone, and "usually restarts" is a worse control than "always stops".
|
||||
///
|
||||
/// Nothing of Claude Code's own is moved, and that is a measurement
|
||||
/// rather than an omission: `claude --resume <id>` finds a session from
|
||||
/// any working directory (checked against 2.1.237 on 2026-08-31 -- an
|
||||
/// id that does not exist says "No conversation found with session ID"
|
||||
/// and a real one resumed from an unrelated directory did not), so the
|
||||
/// conversation continues in the new place with nothing relocated. The
|
||||
/// file stays under the project directory the CLI made for it, which is
|
||||
/// where the CLI itself looks. Reimplementing that directory's name to
|
||||
/// move it would mean reproducing a rule this app cannot see the whole
|
||||
/// of -- the CLI truncates at 200 characters and appends a hash of its
|
||||
/// own, and an override can replace the name entirely -- to relocate a
|
||||
/// file the CLI is still writing.
|
||||
///
|
||||
/// Whether the directory exists is the caller's question, because
|
||||
/// asking it is an ssh round trip on a remote setup; see the route.
|
||||
pub fn set_session_cwd(&self, id: &str, cwd: PathBuf) -> Result<()> {
|
||||
{
|
||||
let mut inner = self.inner.write().unwrap();
|
||||
if !inner.config.sessions.iter().any(|meta| meta.id == id) {
|
||||
bail!("no session {id}");
|
||||
}
|
||||
let mut candidate = inner.config.clone();
|
||||
for meta in candidate.sessions.iter_mut().filter(|meta| meta.id == id) {
|
||||
meta.cwd = Some(cwd.clone());
|
||||
}
|
||||
candidate.save(&self.config_path)?;
|
||||
inner.config = candidate;
|
||||
}
|
||||
// Saved first, so a process that cannot be stopped leaves a session
|
||||
// that will start in the right place rather than one recorded in a
|
||||
// directory nothing agrees with.
|
||||
let dir = self.data_dir.join(id);
|
||||
if let Some(record) = process::live(&dir) {
|
||||
tracing::info!(
|
||||
"moving session {id} to {} -- stopping pid {}",
|
||||
cwd.display(),
|
||||
record.pid
|
||||
);
|
||||
process::stop(&record, process::STOP_GRACE);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop_session(&self, id: &str) -> Result<()> {
|
||||
if !self
|
||||
.inner
|
||||
@@ -2069,10 +2158,26 @@ fn is_news(event: &Event, shared: &Shared) -> bool {
|
||||
/// adopted at startup, or because a driver announced itself, is not news
|
||||
/// that anything ended, and sending it would put "finished" on the phone for
|
||||
/// every session in the config every time the backend restarts.
|
||||
fn notification_for(was: SessionStatus, now: SessionStatus) -> Option<NotificationKind> {
|
||||
///
|
||||
/// `unread` is how many messages the session has been handed and not yet
|
||||
/// started reading, and it suppresses *Finished* for the same reason: with
|
||||
/// one waiting, the turn ending is not the work ending. A message written
|
||||
/// into the tail of a turn is read as soon as that turn's `result` lands, so
|
||||
/// the session goes idle and immediately runs again -- and the phone that
|
||||
/// sent it was told its work had finished, seconds before anything of it had
|
||||
/// been done. It cannot suppress *AwaitingInput*: a question is worth saying
|
||||
/// whatever else is queued behind it, and the queue is precisely what will
|
||||
/// not move until it is answered.
|
||||
fn notification_for(
|
||||
was: SessionStatus,
|
||||
now: SessionStatus,
|
||||
unread: usize,
|
||||
) -> Option<NotificationKind> {
|
||||
match (was, now) {
|
||||
(_, SessionStatus::AwaitingInput) => Some(NotificationKind::AwaitingInput),
|
||||
(SessionStatus::Running | SessionStatus::Compacting, SessionStatus::Idle) => {
|
||||
(SessionStatus::Running | SessionStatus::Compacting, SessionStatus::Idle)
|
||||
if unread == 0 =>
|
||||
{
|
||||
Some(NotificationKind::Finished)
|
||||
}
|
||||
_ => None,
|
||||
@@ -2088,6 +2193,13 @@ async fn pump(
|
||||
commands: Arc<Commands>,
|
||||
notifications: broadcast::Sender<Notification>,
|
||||
) {
|
||||
// Messages the session has been given and not started reading, which is
|
||||
// what makes a turn ending not the same thing as the work ending; see
|
||||
// `notification_for`. Counted from the recorded events rather than asked
|
||||
// of the driver, because this is the one place that sees every event in
|
||||
// the order the transcript has them -- and because the answer has to
|
||||
// survive being asked a moment later than the driver would have said it.
|
||||
let mut unread: usize = 0;
|
||||
while let Some(event) = source.recv().await {
|
||||
let ts = now();
|
||||
// Taking a message is how it enters the conversation, and the
|
||||
@@ -2137,8 +2249,8 @@ async fn pump(
|
||||
// Read before it is overwritten: what makes a status
|
||||
// worth announcing is the transition, not the value.
|
||||
let was = std::mem::replace(&mut *shared.status.lock().unwrap(), *state);
|
||||
if let Some(kind) =
|
||||
notification_for(was, *state).filter(|_| *shared.notify.lock().unwrap())
|
||||
if let Some(kind) = notification_for(was, *state, unread)
|
||||
.filter(|_| *shared.notify.lock().unwrap())
|
||||
{
|
||||
// No subscribers is the ordinary case -- nobody has
|
||||
// the app open -- and it is not an error.
|
||||
@@ -2163,6 +2275,13 @@ async fn pump(
|
||||
Event::Status {
|
||||
state: SessionStatus::Exited,
|
||||
} => commands.abandon("this session's process has exited"),
|
||||
// The two ends of a message's wait. A `UserMessage` with
|
||||
// no id never waited -- it is one sent between turns, and
|
||||
// counting it would take the total below zero.
|
||||
Event::MessageQueued { .. } => unread += 1,
|
||||
Event::UserMessage { id: Some(_), .. } | Event::MessageDropped { .. } => {
|
||||
unread = unread.saturating_sub(1)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// No subscribers is fine; the transcript already has it.
|
||||
@@ -2383,28 +2502,43 @@ mod tests {
|
||||
// Waiting on a person is worth saying however it was reached: it
|
||||
// will sit unanswered until somebody is told.
|
||||
assert_eq!(
|
||||
notification_for(Running, SessionStatus::AwaitingInput),
|
||||
notification_for(Running, SessionStatus::AwaitingInput, 0),
|
||||
Some(AwaitingInput)
|
||||
);
|
||||
assert_eq!(
|
||||
notification_for(Idle, SessionStatus::AwaitingInput),
|
||||
notification_for(Idle, SessionStatus::AwaitingInput, 0),
|
||||
Some(AwaitingInput)
|
||||
);
|
||||
|
||||
// A turn this server watched run, ending.
|
||||
assert_eq!(notification_for(Running, Idle), Some(Finished));
|
||||
assert_eq!(notification_for(Compacting, Idle), Some(Finished));
|
||||
assert_eq!(notification_for(Running, Idle, 0), Some(Finished));
|
||||
assert_eq!(notification_for(Compacting, Idle, 0), Some(Finished));
|
||||
|
||||
// Idle arrived at from anywhere else is not an ending.
|
||||
assert_eq!(notification_for(Idle, Idle), None);
|
||||
assert_eq!(notification_for(Unknown, Idle), None);
|
||||
assert_eq!(notification_for(Exited, Idle), None);
|
||||
assert_eq!(notification_for(SessionStatus::AwaitingInput, Idle), None);
|
||||
assert_eq!(notification_for(Idle, Idle, 0), None);
|
||||
assert_eq!(notification_for(Unknown, Idle, 0), None);
|
||||
assert_eq!(notification_for(Exited, Idle, 0), None);
|
||||
assert_eq!(
|
||||
notification_for(SessionStatus::AwaitingInput, Idle, 0),
|
||||
None
|
||||
);
|
||||
|
||||
// Everything else a session does is progress nobody asked to hear.
|
||||
assert_eq!(notification_for(Idle, Running), None);
|
||||
assert_eq!(notification_for(Running, Compacting), None);
|
||||
assert_eq!(notification_for(Running, Exited), None);
|
||||
assert_eq!(notification_for(Idle, Running, 0), None);
|
||||
assert_eq!(notification_for(Running, Compacting, 0), None);
|
||||
assert_eq!(notification_for(Running, Exited, 0), None);
|
||||
|
||||
// A turn ending with a message the session has not started reading
|
||||
// is not the work ending: it goes straight back to running, and
|
||||
// "finished" would arrive seconds before any of that work was done.
|
||||
assert_eq!(notification_for(Running, Idle, 1), None);
|
||||
assert_eq!(notification_for(Compacting, Idle, 2), None);
|
||||
// A question is still worth saying with a queue behind it -- the
|
||||
// queue is exactly what will not move until it is answered.
|
||||
assert_eq!(
|
||||
notification_for(Running, SessionStatus::AwaitingInput, 1),
|
||||
Some(AwaitingInput)
|
||||
);
|
||||
}
|
||||
|
||||
/// The switch reaches the running pump, not just the config file.
|
||||
@@ -2435,7 +2569,7 @@ mod tests {
|
||||
assert_eq!(first.session_id, info.id);
|
||||
// The title travels with it, because the phone may have no screen
|
||||
// open to look one up on.
|
||||
assert_eq!(first.title, session.info("m", false, None).title);
|
||||
assert_eq!(first.title, session.info("m", None, false, None).title);
|
||||
|
||||
manager.set_session_notify(&info.id, false).expect("off");
|
||||
// Subscribed before the message, or the turn can finish in the gap
|
||||
@@ -2460,6 +2594,51 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Counting the wait, rather than only deciding what to do about it.
|
||||
///
|
||||
/// `notification_for` is tested above on the number; this is the number
|
||||
/// itself, which is kept in `pump` from the recorded events and has no
|
||||
/// other way to be looked at. Echo takes its queued message *before*
|
||||
/// going idle -- the same order a real CLI has when the steer lands
|
||||
/// inside the turn -- so the count is back to zero by the end and the
|
||||
/// finish is still announced. That is the case a suppression written
|
||||
/// slightly wrong silences, and it is the common one.
|
||||
#[tokio::test]
|
||||
async fn a_turn_that_read_its_queued_message_still_announces_its_finish() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let config_path = dir.path().join("config.ron");
|
||||
let data_dir = dir.path().join("sessions");
|
||||
seed_echo_only(&config_path);
|
||||
let manager = SessionManager::new(config_path, data_dir.clone(), data_dir.join("models"))
|
||||
.expect("manager");
|
||||
let info = manager.spawn_session(echo_spec()).expect("spawn");
|
||||
let session = manager.session(&info.id).expect("live");
|
||||
let mut events = session.subscribe();
|
||||
let mut notifications = manager.subscribe_notifications();
|
||||
|
||||
session.send_message("/slow 1".to_string(), Vec::new());
|
||||
collect_until(&mut events, |event| {
|
||||
matches!(
|
||||
event,
|
||||
Event::Status {
|
||||
state: SessionStatus::Running
|
||||
}
|
||||
)
|
||||
})
|
||||
.await;
|
||||
session.send_message("and this behind it".to_string(), Vec::new());
|
||||
collect_until(&mut events, |event| {
|
||||
matches!(event, Event::MessageQueued { .. })
|
||||
})
|
||||
.await;
|
||||
|
||||
let announced = tokio::time::timeout(Duration::from_secs(5), notifications.recv())
|
||||
.await
|
||||
.expect("a notification within five seconds")
|
||||
.expect("channel open");
|
||||
assert_eq!(announced.kind, NotificationKind::Finished);
|
||||
}
|
||||
|
||||
/// A session this app *spawned* is one it is driving, and used to look
|
||||
/// like somebody else's.
|
||||
///
|
||||
@@ -2716,6 +2895,86 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A queued message can be taken back until the driver has handed it
|
||||
/// over, and the taking back is an event rather than a return value --
|
||||
/// which is what makes the bubble disappear on every device watching,
|
||||
/// and stay gone when one of them reconnects and replays.
|
||||
///
|
||||
/// Exercised on echo because echo really holds its queue. The Claude
|
||||
/// driver writes a steer into the CLI the moment it arrives, so it can
|
||||
/// only ever answer `AlreadySent`; the case where a drop *succeeds*
|
||||
/// has no other driver to be tested against.
|
||||
#[tokio::test]
|
||||
async fn a_queued_message_can_be_taken_back_until_the_session_has_it() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
seed_echo_only(&dir.path().join("config.ron"));
|
||||
let manager = SessionManager::new(
|
||||
dir.path().join("config.ron"),
|
||||
dir.path().join("sessions"),
|
||||
dir.path().join("models"),
|
||||
)
|
||||
.expect("manager");
|
||||
let info = manager.spawn_session(echo_spec()).expect("spawn");
|
||||
let session = manager.session(&info.id).expect("live session");
|
||||
let mut rx = session.subscribe();
|
||||
|
||||
// A turn long enough that the next message has to wait behind it.
|
||||
session.send_message("/slow 1".to_string(), Vec::new());
|
||||
collect_until(&mut rx, |event| {
|
||||
matches!(
|
||||
event,
|
||||
Event::Status {
|
||||
state: SessionStatus::Running
|
||||
}
|
||||
)
|
||||
})
|
||||
.await;
|
||||
|
||||
session.send_message("second thoughts".to_string(), Vec::new());
|
||||
let seen = collect_until(&mut rx, |event| {
|
||||
matches!(event, Event::MessageQueued { .. })
|
||||
})
|
||||
.await;
|
||||
let Some(Event::MessageQueued { id, .. }) =
|
||||
seen.iter().map(|entry| entry.event.clone()).next_back()
|
||||
else {
|
||||
panic!("expected the message to be queued: {seen:?}");
|
||||
};
|
||||
|
||||
assert_eq!(session.unqueue(&id), Unqueued::Dropped);
|
||||
let seen = collect_until(&mut rx, |event| {
|
||||
matches!(event, Event::MessageDropped { .. })
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
seen.iter().any(|entry| matches!(
|
||||
&entry.event,
|
||||
Event::MessageDropped { id: dropped } if *dropped == id
|
||||
)),
|
||||
"the drop has to be recorded, not merely returned: {seen:?}"
|
||||
);
|
||||
|
||||
// Gone for good: the turn ends without the message ever entering
|
||||
// the conversation, and asking again says there is nothing there
|
||||
// rather than dropping it twice.
|
||||
assert_eq!(session.unqueue(&id), Unqueued::Unknown);
|
||||
let seen = collect_until(&mut rx, |event| {
|
||||
matches!(
|
||||
event,
|
||||
Event::Status {
|
||||
state: SessionStatus::Idle
|
||||
}
|
||||
)
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
!seen
|
||||
.iter()
|
||||
.any(|entry| matches!(entry.event, Event::UserMessage { .. })),
|
||||
"a message taken back must never be read: {seen:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_command_on_an_idle_session_goes_straight_out() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
Reference in new issue
Block a user