1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
//! The agent sessions running in a project, and what a turn does to one.
//!
//! A continuation of [`Workspace`]'s one `impl`, which is why it opens on
//! `use super::*`: these methods work on the same struct and reach the same
//! names as the rest of it.
use super::*;
impl Workspace {
/// Open a session in the active project. `seed` is its first prompt, sent
/// as soon as the agent is up — what a dispatched card rides in on.
///
/// The one place a session is born, so it is where the sessions switch
/// bites: nothing spawns an agent until it has been turned on.
pub fn new_session(
&mut self,
entry: settings::Agent,
seed: Option<String>,
cx: &mut Context<Self>,
) -> Option<u64> {
if !self.settings.features.sessions {
return None;
}
let ix = self.active?;
let id = self.next_id;
self.next_id += 1;
let chat = ChatSession::connect(id, entry, self.projects[ix].path.clone(), seed, cx);
let project = &mut self.projects[ix];
project.sessions.push(chat);
project.active = Some(id);
cx.notify();
Some(id)
}
/// Every project's sessions are on show, so picking one brings its project
/// forward with it.
pub fn select_session(&mut self, id: u64, cx: &mut Context<Self>) {
let Some(ix) = self.project_of(id) else {
return;
};
self.projects[ix].active = Some(id);
self.active = Some(ix);
// A session has no file until its first turn is written, so one that
// has said nothing is not yet somewhere to come back to.
if let Some(record) = self.projects[ix]
.session(id)
.and_then(|chat| chat.record.clone())
{
self.remember(ix, state::Kind::Session, record);
}
self.wake_session(id, cx);
cx.notify();
}
/// Point a session at an agent, if it has none and could have one.
///
/// Called where a session is brought *forward* rather than where one is
/// created: the session you are looking at is the one you are about to work
/// in, and what an agent reports on connect — its modes, its model — is
/// what the composer needs before the first prompt rather than after it.
///
/// Only ever the one in front. Every other session a project holds stays
/// idle, which is what still keeps a launch from starting an agent per
/// transcript. An archived one stays where it was put.
pub(super) fn wake_session(&mut self, id: u64, cx: &mut Context<Self>) {
if !self.settings.features.sessions {
return;
}
let found = self
.projects
.iter_mut()
.find_map(|project| project.session_mut(id))
.filter(|chat| chat.idle() && chat.resumable() && !chat.closed);
if let Some(chat) = found {
chat.resume(cx);
cx.notify();
}
}
fn project_of(&self, id: u64) -> Option<usize> {
self.projects
.iter()
.position(|project| project.session(id).is_some())
}
/// Read the project's filed sessions back, minting an id for each — ids
/// mean nothing across a launch, so a reloaded one is as new as any. The
/// agent is resolved by name; a session whose agent has since left
/// `settings.toml` comes back readable but cannot reconnect.
pub(super) fn restore_sessions(&mut self, ix: usize) {
let path = self.projects[ix].path.clone();
for stored in self.projects[ix].store().sessions() {
let id = self.next_id;
self.next_id += 1;
let entry = super::named(
&self.settings.agents,
stored.agent_id.as_deref(),
&stored.agent,
)
.cloned()
// Nothing on this machine answers to it. The placeholder is a
// name to show and no command to start, which is what
// `Workspace::reachable` reads as stranded — and it keeps the
// id, so the record is written back as findable as it arrived.
.unwrap_or_else(|| settings::Agent {
name: stored.agent.clone(),
id: stored.agent_id.clone(),
command: String::new(),
args: Vec::new(),
env: Default::default(),
});
let chat = ChatSession::restore(id, path.clone(), entry, stored);
self.projects[ix].sessions.push(chat);
}
}
/// Send to a session, starting an agent for it when it has none — typing
/// into a session read back from disk is what picks it up again.
pub fn send(&mut self, id: u64, content: String, cx: &mut Context<Self>) {
// Read before `chat` borrows the projects. This is the second place an
// agent process starts, so it is the second half of the gate.
let enabled = self.settings.features.sessions;
let found = self
.projects
.iter_mut()
.find_map(|project| project.session_mut(id));
let Some(chat) = found else {
return;
};
// Said out loud rather than queued: with no agent to drain it, a
// prompt pushed onto the queue reads as a message that went nowhere.
if !enabled && !chat.live() {
chat.notice(
true,
"sessions are off — turn them on in Settings › Features",
);
cx.notify();
return;
}
if chat.idle() && chat.resumable() {
chat.resume(cx);
}
chat.send(content);
let record = chat.record.clone();
if let (Some(record), Some(ix)) = (record, self.project_of(id)) {
self.remember(ix, state::Kind::Session, record);
}
cx.notify();
}
/// Close the connection and keep the transcript. The row stays where it
/// was, readable, and typing into it opens an agent again.
/// Put a session away, or bring it back. Closing tears the agent down and
/// keeps the transcript; opening it again is what reconnects.
pub fn archive_session(&mut self, id: u64, archived: bool, cx: &mut Context<Self>) {
self.with_session(id, cx, |chat| match archived {
true => chat.close(),
false => chat.closed = false,
});
}
pub fn rename_session(&mut self, id: u64, name: String, cx: &mut Context<Self>) {
self.with_session(id, cx, |chat| {
let name = name.trim();
chat.name = (!name.is_empty()).then(|| name.to_owned());
chat.flush();
});
}
pub fn close_session(&mut self, id: u64, cx: &mut Context<Self>) {
let Some(project) = self.project_of(id).map(|ix| &mut self.projects[ix]) else {
return;
};
// Closing a session is what deletes it: leaving the file would put
// the row back on the next launch.
if let Some(record) = project.session(id).and_then(|chat| chat.record.clone()) {
project.store().remove_session(&record);
}
project.sessions.retain(|chat| chat.id != id);
if project.active == Some(id) {
project.active = project.sessions.first().map(|chat| chat.id);
}
cx.notify();
}
/// Any session, in whichever project holds it — the pump that feeds a
/// session knows only its id, and must not care which tab it sits behind.
pub fn session(&self, id: u64) -> Option<&ChatSession> {
self.projects.iter().find_map(|project| project.session(id))
}
/// The session filed under this id, wherever it is — how a card finds the
/// agent it was handed to after a launch that renumbered every session.
/// [`ChatSession::id`] is minted per launch and means nothing on disk;
/// this is the name that keeps.
pub fn session_by_record(&self, record: &str) -> Option<&ChatSession> {
self.projects
.iter()
.flat_map(|project| project.sessions.iter())
.find(|chat| chat.record.as_deref() == Some(record))
}
/// The id a session is filed under, minted if it has none — what a
/// dispatched card writes down.
pub fn mint_record(&mut self, id: u64) -> Option<String> {
self.projects
.iter_mut()
.find_map(|project| project.session_mut(id))?
.mint_record()
.map(str::to_owned)
}
/// Run `f` on the session (when it still exists) and repaint.
pub fn with_session(
&mut self,
id: u64,
cx: &mut Context<Self>,
f: impl FnOnce(&mut ChatSession),
) {
let found = self
.projects
.iter_mut()
.find_map(|project| project.session_mut(id));
if let Some(chat) = found {
f(chat);
cx.notify();
}
}
/// Put what the transcript has selected on the clipboard — see
/// [`crate::view::component::transcript::State::copied`].
///
/// Answers whether there was anything, so a `cmd-c` that finds no selection
/// can be left to whatever else wanted it.
pub fn copy_selection(&mut self, cx: &mut Context<Self>) -> bool {
let Some(text) = self
.active_session()
.and_then(|chat| chat.transcript.copied(chat))
else {
return false;
};
cx.write_to_clipboard(ClipboardItem::new_string(text));
true
}
/// Switch a session's mode — what the composer's mode picker reports.
/// See [`ChatSession::set_mode`].
pub fn set_session_mode(&mut self, id: u64, mode_id: String, cx: &mut Context<Self>) {
self.with_session(id, cx, |chat| chat.set_mode(&mode_id));
}
/// The same for a config option, which is where the model lives.
pub fn set_session_config(
&mut self,
id: u64,
config_id: String,
value: SessionConfigOptionValue,
cx: &mut Context<Self>,
) {
self.with_session(id, cx, |chat| chat.set_config(&config_id, value));
}
/// The session reached an agent: send it whatever was typed while it had
/// none, and write the agent's own id down so a later launch can load the
/// conversation back.
pub fn session_connected(&mut self, id: u64, cx: &mut Context<Self>) {
self.with_session(id, cx, |chat| {
chat.drain();
chat.flush();
});
cx.notify();
}
/// The session the chat pane would show. Gated, and it is the gate that
/// matters most: sessions are read back off disk when a project opens,
/// whatever the switch says, so without this a filed transcript would put
/// the pane on screen with no composer under it.
pub fn active_session(&self) -> Option<&ChatSession> {
self.settings
.features
.sessions
.then(|| self.active_project()?.active_session())
.flatten()
}
pub fn active_id(&self) -> Option<u64> {
self.active_project().and_then(|project| project.active)
}
/// Whether the session in front has anywhere to send: it is talking to an
/// agent already, or `settings.toml` still names the one it would start.
///
/// Stronger than [`ChatSession::resumable`], which asks only whether the
/// session was handed a command. A session outlives the agent it was
/// opened on — uninstalled in Settings › Agents, or edited out of the
/// file — and the copy of the entry it is holding says nothing about
/// whether that command is still on this machine. What is left reads back
/// and cannot be answered into, which is what the composer's absence has
/// to mean.
///
/// A live session is reachable whatever the file says. It is running: the
/// agent was uninstalled out from under a conversation, and taking the
/// composer away mid-turn would strand it.
pub fn reachable(&self) -> bool {
let Some(chat) = self.active_session() else {
return false;
};
chat.live() || (chat.resumable() && self.agent_for(&chat.entry).is_some())
}
}