okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.pettui: rename and delete sessions straight from the list, and keep an archive from being undone by an older listing5e287ce

main
9.2 KiB275 linesraw
1package tui
2
3import (
4	"albedo/cli/internal/daemon"
5	"context"
6	"errors"
7	"maps"
8	"slices"
9	"time"
10
11	tea "charm.land/bubbletea/v2"
12)
13
14func (m *AppModel) handleSessionResults(msg tea.Msg) (tea.Cmd, bool) {
15	switch msg := msg.(type) {
16	case SessionPreviewMsg:
17		if msg.Err == nil && msg.Preview.Session != nil {
18			current, known := m.session(msg.ID)
19			if known && current.ETag == msg.ExpectedETag {
20				m.updateSession(*msg.Preview.Session)
21			}
22		}
23		var cmd tea.Cmd
24		m.SessionPicker, cmd = m.SessionPicker.Update(msg)
25		return cmd, true
26
27	case sessionDeletedMsg:
28		if msg.Err != nil {
29			m.AddError(operationError(msg.Err, "Could not delete the session: ", "Session may have been deleted; refresh the session list before trying again."))
30			return nil, true
31		}
32		m.ClearNotices()
33		if msg.Result == nil {
34			return nil, true
35		}
36		deleted := slices.Clone(msg.Result.DeletedIDs)
37		if msg.Result.OK && !slices.Contains(deleted, msg.ID) {
38			deleted = append(deleted, msg.ID)
39		}
40		for _, id := range deleted {
41			delete(m.pendingBySession, id)
42			m.SessionPicker.ForgetPreview(id)
43		}
44		m.Sessions = slices.DeleteFunc(m.Sessions, func(s daemon.Session) bool { return slices.Contains(deleted, s.ID) })
45		m.updateSessionPickerItems()
46		if !msg.Result.OK || msg.Result.Truncated {
47			m.AddNotice(msg.Result.Message)
48			m.SessionPicker.notice = msg.Result.Message
49			m.SessionGen++
50			return m.loadSessionsCmd(m.SessionGen, msg.Result.Message), true
51		}
52		return nil, true
53	case SessionRenameMsg:
54		m.SessionGen++
55		return m.renameSessionCmd(msg), true
56	case sessionRenamedMsg:
57		var cmd tea.Cmd
58		if m.State == AppStateAgents {
59			m.Agents, cmd = m.Agents.Update(msg)
60		}
61		if msg.Err != nil {
62			m.SessionPicker.notice = operationError(msg.Err, "Could not rename the session: ", "Session may have been renamed; refresh the session list before trying again.")
63			return cmd, true
64		}
65		m.updateSession(msg.Session)
66		m.SessionPicker.notice = ""
67		return cmd, true
68	case sessionsLoadedMsg:
69		if msg.Gen != m.SessionGen {
70			return nil, true
71		}
72		m.listingSessions = false
73		if msg.Err != nil {
74			m.SessionPicker.Loading = false
75			m.AddError("Could not load sessions: " + msg.Err.Error())
76			return nil, true
77		}
78		m.applySessionList(msg.Sessions)
79
80		m.ClearNotices()
81		if msg.Notice != "" {
82			m.AddNotice(msg.Notice)
83			m.SessionPicker.notice = msg.Notice
84		}
85		return m.SessionPicker.PreviewCmd(), true
86	case sessionCreationRecoveryMsg:
87		if pending, exists := m.pendingCreations[msg.Handle.ID()]; !exists || pending.Expired {
88			return nil, true
89		}
90		conn, handle, gen := m.Conn, msg.Handle, msg.Gen
91		return func() tea.Msg {
92			session, err := daemon.ResolveCreation(context.Background(), conn, handle)
93			return sessionCreatedMsg{Session: session, Err: err, Handle: handle, Gen: gen}
94		}, true
95	case sessionCreatedMsg:
96		if msg.Gen != m.SessionGen && (msg.Handle == nil || m.pendingCreations[msg.Handle.ID()].Handle == nil) {
97			return nil, true
98		}
99		if msg.Handle != nil && daemon.IsOperationExpired(msg.Err) {
100			pending := m.pendingCreations[msg.Handle.ID()]
101			pending.Expired = true
102			m.pendingCreations[msg.Handle.ID()] = pending
103			m.AddError("Session operation " + msg.Handle.ID() + " expired; its outcome is unresolved.")
104			return nil, true
105		}
106		if msg.Err != nil && msg.Handle != nil {
107			if _, uncertain := errors.AsType[*daemon.UncertainOutcomeError](msg.Err); uncertain {
108				m.AddError("Session admission is uncertain. Checking operation " + msg.Handle.ID() + ".")
109				return creationRecoveryCmd(msg.Handle, msg.Gen), true
110			}
111			if api, ok := errors.AsType[*daemon.APIError](msg.Err); ok && (api.Code == "operation_unknown" || api.StatusCode >= 500) {
112				return creationRecoveryCmd(msg.Handle, msg.Gen), true
113			}
114		}
115		if msg.Handle != nil {
116			delete(m.pendingCreations, msg.Handle.ID())
117		}
118		if msg.Gen != m.SessionGen {
119			if msg.Err != nil {
120				m.AddError(msg.Err.Error())
121				return nil, true
122			}
123			if !slices.ContainsFunc(m.Sessions, func(session daemon.Session) bool { return session.ID == msg.Session.ID }) {
124				m.Sessions = append(m.Sessions, msg.Session)
125			}
126			m.updateSessionPickerItems()
127			m.ClearNotices()
128			m.AddNotice("Recovered the created session " + msg.Session.ID)
129			return nil, true
130		}
131		if msg.Err != nil && m.State == AppStateFolderPicker {
132			m.FolderPicker.Refused(errors.New(operationError(msg.Err, "", "Session may have been created; check the session list before creating another.")))
133			return nil, true
134		}
135		if msg.Err != nil {
136			m.AddError(operationError(msg.Err, "Could not create a session: ", "Session may have been created; check the session list before creating another."))
137			return nil, true
138		}
139		return m.setChatSession(msg.Session, true), true
140	}
141	return nil, false
142}
143
144func (m *AppModel) loadSessionsCmd(gen int, notices ...string) tea.Cmd {
145	m.listingSessions = true
146	conn := m.Conn
147	notice := ""
148	if len(notices) > 0 {
149		notice = notices[0]
150	}
151	return func() tea.Msg {
152		if conn == nil {
153			return sessionsLoadedMsg{Err: errors.New("daemon connection unavailable"), Gen: gen}
154		}
155		sessions, err := daemon.ListSessions(context.Background(), conn)
156		return sessionsLoadedMsg{Sessions: sessions, Err: err, Gen: gen, Notice: notice}
157	}
158}
159
160func (m *AppModel) renameSessionCmd(rename SessionRenameMsg) tea.Cmd {
161	conn := m.Conn
162	return func() tea.Msg {
163		if conn == nil {
164			return sessionRenamedMsg{SessionRenameMsg: rename, Err: errors.New("daemon connection unavailable")}
165		}
166		etag := rename.ETag
167		if etag == "" {
168			current, err := currentCondition(conn, rename.ID)
169			if err != nil {
170				return sessionRenamedMsg{SessionRenameMsg: rename, Err: err}
171			}
172			etag = current.ETag
173		}
174		s, err := daemon.RenameSession(context.Background(), conn, rename.ID, rename.Name, etag)
175		return sessionRenamedMsg{SessionRenameMsg: rename, Session: s, Err: err}
176	}
177}
178
179func (m *AppModel) deleteSessionCmd(id string, condition daemon.SessionCondition) tea.Cmd {
180	m.SessionGen++
181	conn := m.Conn
182	return func() tea.Msg {
183		if conn == nil {
184			return sessionDeletedMsg{ID: id, Err: errors.New("daemon connection unavailable")}
185		}
186		if condition.ETag == "" || condition.FamilyRevision == "" {
187			current, err := currentCondition(conn, id)
188			if err != nil {
189				return sessionDeletedMsg{ID: id, Err: err}
190			}
191			condition = current
192		}
193		result, err := daemon.DeleteSession(context.Background(), conn, id, true, condition)
194		return sessionDeletedMsg{ID: id, Err: err, Result: &result}
195	}
196}
197
198// currentCondition reads validators missing from a selected session row.
199func currentCondition(conn *daemon.Connection, id string) (daemon.SessionCondition, error) {
200	current, err := daemon.GetSessionConfiguration(context.Background(), conn, id)
201	return daemon.SessionCondition{ETag: current.ETag, FamilyRevision: current.Value.FamilyRevision}, err
202}
203
204func sessionPreviewCmd(conn *daemon.Connection, id, etag string) tea.Cmd {
205	return func() tea.Msg {
206		preview, err := daemon.PreviewHistory(context.Background(), conn, id, 16)
207		return SessionPreviewMsg{ID: id, ExpectedETag: etag, Preview: preview, Err: err}
208	}
209}
210
211func (m *AppModel) createSessionCmd(gen int, workspace string) tea.Cmd {
212	conn := m.Conn
213	handle, preparationErr := daemon.NewCreation(daemon.CreateSessionRequest{Workspace: workspace})
214	if preparationErr == nil {
215		if m.pendingCreations == nil {
216			m.pendingCreations = make(map[string]pendingOperation)
217		}
218		m.pendingCreations[handle.ID()] = pendingOperation{Handle: handle}
219	}
220	return func() tea.Msg {
221		if preparationErr != nil {
222			return sessionCreatedMsg{Err: preparationErr, Gen: gen}
223		}
224		if conn == nil {
225			return sessionCreatedMsg{Err: errors.New("daemon connection unavailable"), Gen: gen, Handle: handle}
226		}
227		session, err := daemon.CreateSessionOperation(context.Background(), conn, handle)
228		return sessionCreatedMsg{Session: session, Err: err, Gen: gen, Handle: handle}
229	}
230}
231
232func creationRecoveryCmd(handle *daemon.OperationHandle, gen int) tea.Cmd {
233	return tea.Tick(15*time.Second, func(time.Time) tea.Msg { return sessionCreationRecoveryMsg{Handle: handle, Gen: gen} })
234}
235
236func (m *AppModel) updateSessionPickerItems() {
237	m.SessionPicker.SetSessions(m.Sessions, m.ActiveSession)
238}
239
240func (m *AppModel) setChatSession(s daemon.Session, prepend bool) tea.Cmd {
241	m.SessionGen++
242	if m.pendingBySession == nil {
243		m.pendingBySession = make(map[string]pendingSessionIntents)
244	}
245	if m.Chat.SessionID != "" {
246		if len(m.Chat.pendingUsers) == 0 && len(m.Chat.pendingContinuations) == 0 {
247			delete(m.pendingBySession, m.Chat.SessionID)
248		} else {
249			m.pendingBySession[m.Chat.SessionID] = pendingSessionIntents{users: slices.Clone(m.Chat.pendingUsers), continuations: maps.Clone(m.Chat.pendingContinuations)}
250		}
251	}
252	m.Chat.Close()
253	session := s
254	m.ActiveSession = &session
255	if prepend && !slices.ContainsFunc(m.Sessions, func(listed daemon.Session) bool { return listed.ID == s.ID }) {
256		m.Sessions = append([]daemon.Session{s}, m.Sessions...)
257	}
258	m.updateSession(s)
259	m.Chat = m.newChatModel(&session)
260	m.Chat.SetSize(m.Width, m.Height)
261	m.State = AppStateChat
262	m.CatalogGen++
263	return tea.Batch(m.Chat.Init(), m.loadCommandCatalogCmd(m.CatalogGen))
264}
265
266// openSession makes session the chat on screen.
267func (m *AppModel) openSession(s daemon.Session) tea.Cmd {
268	m.ClearNotices()
269	return tea.Batch(m.setChatSession(s, false), m.recordOpenCmd(s.ID))
270}
271
272func (m *AppModel) newSessionCmd() tea.Cmd {
273	m.SessionGen++
274	return m.createSessionCmd(m.SessionGen, m.Workspace)
275}