Let a queued message be taken back into the composer
This commit is contained in:
@@ -2,9 +2,6 @@
|
|||||||
|
|
||||||
# M1 — One agent end to end
|
# M1 — One agent end to end
|
||||||
|
|
||||||
- Cancel a queued message
|
|
||||||
A × on the transcript's "queued…" bubble withdraws the `task_inputs` row with a new `DELETE`, refused once the message has been handed over, and puts its text and images back in the composer. Claude Code, Zed and Devin all let a queued follow-up be taken back.
|
|
||||||
|
|
||||||
# M2 — Seeing the work
|
# M2 — Seeing the work
|
||||||
|
|
||||||
- Comment on diff lines in the Changes view
|
- Comment on diff lines in the Changes view
|
||||||
|
|||||||
@@ -762,6 +762,20 @@ paths:
|
|||||||
"404": { $ref: "#/components/responses/Error" }
|
"404": { $ref: "#/components/responses/Error" }
|
||||||
"409": { $ref: "#/components/responses/Error" }
|
"409": { $ref: "#/components/responses/Error" }
|
||||||
|
|
||||||
|
/api/tasks/{taskId}/queue/{inputId}:
|
||||||
|
parameters:
|
||||||
|
- { name: taskId, in: path, required: true, schema: { type: string } }
|
||||||
|
- { name: inputId, in: path, required: true, schema: { type: integer, format: int64 } }
|
||||||
|
delete:
|
||||||
|
operationId: withdrawMessage
|
||||||
|
description: >
|
||||||
|
Takes back a queued message (the id from its user.queued event) before the agent gets it, and records a
|
||||||
|
user.unqueued event. Refused once the message has been handed over.
|
||||||
|
responses:
|
||||||
|
"204": { description: The message was withdrawn. }
|
||||||
|
"404": { $ref: "#/components/responses/Error" }
|
||||||
|
"409": { $ref: "#/components/responses/Error" }
|
||||||
|
|
||||||
/api/sessions/{sessionId}:
|
/api/sessions/{sessionId}:
|
||||||
parameters:
|
parameters:
|
||||||
- { name: sessionId, in: path, required: true, schema: { type: string } }
|
- { name: sessionId, in: path, required: true, schema: { type: string } }
|
||||||
|
|||||||
+3
-3
File diff suppressed because one or more lines are too long
@@ -75,7 +75,42 @@ func (m *Manager) Message(ctx context.Context, sessionID, actor, text, model str
|
|||||||
return sess, nil
|
return sess, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Withdraw(ctx context.Context, taskID string, inputID int64, actor string) error {
|
||||||
|
task, err := m.store.GetTask(ctx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sessions, err := m.store.ListSessions(ctx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(sessions) == 0 {
|
||||||
|
return fmt.Errorf("%w: message %d is not queued", ErrConflict, inputID)
|
||||||
|
}
|
||||||
|
sess := sessions[len(sessions)-1]
|
||||||
|
|
||||||
|
unlock := m.lockDelivery(queueLockKey(taskID))
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
|
err = m.store.WithdrawTaskInput(ctx, taskID, inputID, task.ProjectId, sess.Id, actor)
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
return fmt.Errorf("%w: message %d is not queued", ErrConflict, inputID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
slog.Info("queued message withdrawn", "session", sess.Id, "task", taskID, "input", inputID, "actor", actor)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func queueLockKey(taskID string) string {
|
||||||
|
return "task:" + taskID
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) deliverQueued(ctx context.Context, sess api.AgentSession) (bool, error) {
|
func (m *Manager) deliverQueued(ctx context.Context, sess api.AgentSession) (bool, error) {
|
||||||
|
unlock := m.lockDelivery(queueLockKey(sess.TaskId))
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
inputs, err := m.store.TaskInputs(ctx, sess.TaskId)
|
inputs, err := m.store.TaskInputs(ctx, sess.TaskId)
|
||||||
if err != nil || len(inputs) == 0 {
|
if err != nil || len(inputs) == 0 {
|
||||||
return false, err
|
return false, err
|
||||||
@@ -131,6 +166,9 @@ func (m *Manager) takeQueuedMidTurn(ctx context.Context, l *live) []store.TaskIn
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
unlock := m.lockDelivery(queueLockKey(l.taskID))
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
inputs, err := m.store.TaskInputs(ctx, l.taskID)
|
inputs, err := m.store.TaskInputs(ctx, l.taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("list queued messages", "session", l.id, "err", err)
|
slog.Error("list queued messages", "session", l.id, "err", err)
|
||||||
|
|||||||
+60
-19
@@ -1624,6 +1624,9 @@ type ServerInterface interface {
|
|||||||
// (GET /api/tasks/{taskId}/previews)
|
// (GET /api/tasks/{taskId}/previews)
|
||||||
ListPreviews(w http.ResponseWriter, r *http.Request, taskId string)
|
ListPreviews(w http.ResponseWriter, r *http.Request, taskId string)
|
||||||
|
|
||||||
|
// (DELETE /api/tasks/{taskId}/queue/{inputId})
|
||||||
|
WithdrawMessage(w http.ResponseWriter, r *http.Request, taskId string, inputId int64)
|
||||||
|
|
||||||
// (GET /api/tasks/{taskId}/reviews)
|
// (GET /api/tasks/{taskId}/reviews)
|
||||||
ListReviews(w http.ResponseWriter, r *http.Request, taskId string)
|
ListReviews(w http.ResponseWriter, r *http.Request, taskId string)
|
||||||
|
|
||||||
@@ -3008,6 +3011,41 @@ func (siw *ServerInterfaceWrapper) ListPreviews(w http.ResponseWriter, r *http.R
|
|||||||
handler.ServeHTTP(w, r)
|
handler.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithdrawMessage operation middleware
|
||||||
|
func (siw *ServerInterfaceWrapper) WithdrawMessage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
var err error
|
||||||
|
_ = err
|
||||||
|
|
||||||
|
// ------------- Path parameter "taskId" -------------
|
||||||
|
var taskId string
|
||||||
|
|
||||||
|
err = runtime.BindStyledParameterWithOptions("simple", "taskId", r.PathValue("taskId"), &taskId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
|
||||||
|
if err != nil {
|
||||||
|
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "taskId", Err: err})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------- Path parameter "inputId" -------------
|
||||||
|
var inputId int64
|
||||||
|
|
||||||
|
err = runtime.BindStyledParameterWithOptions("simple", "inputId", r.PathValue("inputId"), &inputId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "integer", Format: "int64", ValueIsUnescaped: true})
|
||||||
|
if err != nil {
|
||||||
|
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "inputId", Err: err})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
siw.Handler.WithdrawMessage(w, r, taskId, inputId)
|
||||||
|
}))
|
||||||
|
|
||||||
|
for _, middleware := range siw.HandlerMiddlewares {
|
||||||
|
handler = middleware(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
// ListReviews operation middleware
|
// ListReviews operation middleware
|
||||||
func (siw *ServerInterfaceWrapper) ListReviews(w http.ResponseWriter, r *http.Request) {
|
func (siw *ServerInterfaceWrapper) ListReviews(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
@@ -3562,6 +3600,7 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
|
|||||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/tasks/{taskId}/snapshots/{n}/fork", wrapper.ForkSnapshot)
|
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/tasks/{taskId}/snapshots/{n}/fork", wrapper.ForkSnapshot)
|
||||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/sessions", wrapper.ListSessions)
|
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/sessions", wrapper.ListSessions)
|
||||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/tasks/{taskId}/sessions", wrapper.CreateSession)
|
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/tasks/{taskId}/sessions", wrapper.CreateSession)
|
||||||
|
m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/api/tasks/{taskId}/queue/{inputId}", wrapper.WithdrawMessage)
|
||||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/sessions/{sessionId}", wrapper.GetSession)
|
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/sessions/{sessionId}", wrapper.GetSession)
|
||||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/sessions/{sessionId}/messages", wrapper.SendMessage)
|
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/sessions/{sessionId}/messages", wrapper.SendMessage)
|
||||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/sessions/{sessionId}/compact", wrapper.CompactSession)
|
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/sessions/{sessionId}/compact", wrapper.CompactSession)
|
||||||
@@ -3798,25 +3837,27 @@ var swaggerSpec = []string{
|
|||||||
"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",
|
||||||
}
|
}
|
||||||
|
|
||||||
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
|
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
|
||||||
|
|||||||
@@ -779,6 +779,15 @@ func (s *Server) SendMessage(w http.ResponseWriter, r *http.Request, sessionID s
|
|||||||
writeJSON(w, http.StatusAccepted, sess)
|
writeJSON(w, http.StatusAccepted, sess)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) WithdrawMessage(w http.ResponseWriter, r *http.Request, taskID string, inputID int64) {
|
||||||
|
err := s.agents.Withdraw(r.Context(), taskID, inputID, actor(r))
|
||||||
|
if err != nil {
|
||||||
|
writeAgentError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) CompactSession(w http.ResponseWriter, r *http.Request, sessionID string) {
|
func (s *Server) CompactSession(w http.ResponseWriter, r *http.Request, sessionID string) {
|
||||||
sess, err := s.agents.Compact(r.Context(), sessionID)
|
sess, err := s.agents.Compact(r.Context(), sessionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -26,15 +26,36 @@ func (s *Store) AddTaskInput(ctx context.Context, in TaskInput, projectID, sessi
|
|||||||
payload["images"] = in.Images
|
payload["images"] = in.Images
|
||||||
}
|
}
|
||||||
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
||||||
_, err := tx.ExecContext(ctx, `INSERT INTO task_inputs (task_id, actor, text, images, created_at) VALUES (?, ?, ?, ?, ?)`,
|
res, err := tx.ExecContext(ctx, `INSERT INTO task_inputs (task_id, actor, text, images, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||||
in.TaskID, in.Actor, in.Text, string(images), now())
|
in.TaskID, in.Actor, in.Text, string(images), now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
payload["id"], err = res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return emit(sessionEvent("user.queued", projectID, in.TaskID, "", sessionID, in.Actor, payload))
|
return emit(sessionEvent("user.queued", projectID, in.TaskID, "", sessionID, in.Actor, payload))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) WithdrawTaskInput(ctx context.Context, taskID string, id int64, projectID, sessionID, actor string) error {
|
||||||
|
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
||||||
|
res, err := tx.ExecContext(ctx, `DELETE FROM task_inputs WHERE id = ? AND task_id = ?`, id, taskID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return emit(sessionEvent("user.unqueued", projectID, taskID, "", sessionID, actor, map[string]any{"id": id}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) TaskInputs(ctx context.Context, taskID string) ([]TaskInput, error) {
|
func (s *Store) TaskInputs(ctx context.Context, taskID string) ([]TaskInput, error) {
|
||||||
rows, err := s.db.QueryContext(ctx, `SELECT id, task_id, actor, text, images, created_at FROM task_inputs WHERE task_id = ? ORDER BY id`, taskID)
|
rows, err := s.db.QueryContext(ctx, `SELECT id, task_id, actor, text, images, created_at FROM task_inputs WHERE task_id = ? ORDER BY id`, taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -264,6 +264,29 @@ final class ChatModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func withdraw(_ p: PendingSend, _ overlays: Overlays) async {
|
||||||
|
guard let taskId, let inputId = p.inputId else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let key = "message:\(taskId)"
|
||||||
|
do {
|
||||||
|
var imgs: [PendingImage] = []
|
||||||
|
for id in p.imageIds {
|
||||||
|
let data = try await ImageCache.fetch(.blob(id), store.api)
|
||||||
|
guard let image = NSImage(data: data) else {
|
||||||
|
throw ApiError(status: 0, message: "not an image")
|
||||||
|
}
|
||||||
|
imgs.append(PendingImage(data: data, image: image))
|
||||||
|
}
|
||||||
|
_ = try await store.api.client.withdrawMessage(path: .init(taskId: taskId, inputId: Int64(inputId))).noContent
|
||||||
|
builder.withdrawn(inputId)
|
||||||
|
store.drafts?.restoreText(key, p.text)
|
||||||
|
store.images.restore(key, imgs)
|
||||||
|
} catch {
|
||||||
|
overlays.showAlert(errorText(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func interrupt(_ sessionId: String, _ overlays: Overlays) {
|
func interrupt(_ sessionId: String, _ overlays: Overlays) {
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
@@ -525,7 +548,7 @@ private struct TranscriptScroll: View {
|
|||||||
RowView(row: row, model: model, nested: false)
|
RowView(row: row, model: model, nested: false)
|
||||||
}
|
}
|
||||||
ForEach(model.builder.pending) { p in
|
ForEach(model.builder.pending) { p in
|
||||||
PendingView(pending: p)
|
PendingView(pending: p, model: model)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -647,13 +670,30 @@ private final class ScannerView: NSView {
|
|||||||
|
|
||||||
private struct PendingView: View {
|
private struct PendingView: View {
|
||||||
let pending: PendingSend
|
let pending: PendingSend
|
||||||
|
let model: ChatModel
|
||||||
|
@Environment(Overlays.self) private var overlays
|
||||||
|
@ViewState private var withdrawing = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TrailingBubble {
|
TrailingBubble {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
Text(pending.queued ? "queued…" : "sending…")
|
Text(pending.queued ? "queued…" : "sending…")
|
||||||
.font(Fonts.font(11, italic: true))
|
.font(Fonts.font(11, italic: true))
|
||||||
.foregroundStyle(Palette.muted)
|
.foregroundStyle(Palette.muted)
|
||||||
|
if pending.inputId != nil {
|
||||||
|
Button("×") {
|
||||||
|
withdrawing = true
|
||||||
|
Task {
|
||||||
|
await model.withdraw(pending, overlays)
|
||||||
|
withdrawing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(ThumbRemoveStyle())
|
||||||
|
.disabled(withdrawing)
|
||||||
|
.help("Take this message back into the composer")
|
||||||
|
}
|
||||||
|
}
|
||||||
if !pending.images.isEmpty {
|
if !pending.images.isEmpty {
|
||||||
FlowLayout(spacing: 6) {
|
FlowLayout(spacing: 6) {
|
||||||
ForEach(pending.images) { img in
|
ForEach(pending.images) { img in
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ struct Thumbnails: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ThumbRemoveStyle: ButtonStyle {
|
struct ThumbRemoveStyle: ButtonStyle {
|
||||||
func makeBody(configuration: ButtonStyleConfiguration) -> some View {
|
func makeBody(configuration: ButtonStyleConfiguration) -> some View {
|
||||||
ThumbRemove(configuration: configuration)
|
ThumbRemove(configuration: configuration)
|
||||||
}
|
}
|
||||||
@@ -371,6 +371,11 @@ struct ComposerView: View {
|
|||||||
store.drafts?.setComposerImages(draftKey, store.images.images(draftKey).count)
|
store.drafts?.setComposerImages(draftKey, store.images.images(draftKey).count)
|
||||||
reportDraft()
|
reportDraft()
|
||||||
}
|
}
|
||||||
|
.onChange(of: store.drafts?.composerText(draftKey)) { _, new in
|
||||||
|
if let new, new != text {
|
||||||
|
text = new
|
||||||
|
}
|
||||||
|
}
|
||||||
.onChange(of: store.images.images(draftKey).count) {
|
.onChange(of: store.images.images(draftKey).count) {
|
||||||
store.drafts?.setComposerImages(draftKey, store.images.images(draftKey).count)
|
store.drafts?.setComposerImages(draftKey, store.images.images(draftKey).count)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,15 +192,20 @@ final class Drafts {
|
|||||||
if ok {
|
if ok {
|
||||||
lostImages[send.key] = nil
|
lostImages[send.key] = nil
|
||||||
} else {
|
} else {
|
||||||
let text = [send.text, composerTexts[send.key] ?? ""].filter { !$0.isEmpty }.joined(separator: "\n\n")
|
restoreText(send.key, send.text)
|
||||||
if !text.isEmpty {
|
|
||||||
composerTexts[send.key] = text
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
dirty = true
|
dirty = true
|
||||||
flush()
|
flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func restoreText(_ key: String, _ text: String) {
|
||||||
|
let joined = [text, composerTexts[key] ?? ""].filter { !$0.isEmpty }.joined(separator: "\n\n")
|
||||||
|
if !joined.isEmpty {
|
||||||
|
composerTexts[key] = joined
|
||||||
|
}
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
func checkedAnswer(_ id: String) -> [String] {
|
func checkedAnswer(_ id: String) -> [String] {
|
||||||
checked[id] ?? []
|
checked[id] ?? []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ final class ImageCache {
|
|||||||
return img
|
return img
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func fetch(_ source: ImageSource, _ api: API) async throws -> Data {
|
static func fetch(_ source: ImageSource, _ api: API) async throws -> Data {
|
||||||
switch source {
|
switch source {
|
||||||
case .data(let data):
|
case .data(let data):
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ struct PendingSend: Identifiable {
|
|||||||
let local: Bool
|
let local: Bool
|
||||||
let queued: Bool
|
let queued: Bool
|
||||||
let md: [MDBlock]
|
let md: [MDBlock]
|
||||||
|
var inputId: Int? = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -231,6 +232,10 @@ final class TranscriptBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func withdrawn(_ inputId: Int) {
|
||||||
|
pending.removeAll { $0.inputId == inputId }
|
||||||
|
}
|
||||||
|
|
||||||
private func confirmPending(_ text: String, queued: Bool, local: Bool) {
|
private func confirmPending(_ text: String, queued: Bool, local: Bool) {
|
||||||
let i = queued ? pending.firstIndex { !$0.local && $0.text == text } : nil
|
let i = queued ? pending.firstIndex { !$0.local && $0.text == text } : nil
|
||||||
if let i = i ?? (local ? pending.firstIndex { $0.local && $0.text == text } : nil) {
|
if let i = i ?? (local ? pending.firstIndex { $0.local && $0.text == text } : nil) {
|
||||||
@@ -417,7 +422,13 @@ final class TranscriptBuilder {
|
|||||||
confirmPending(text, queued: false, local: true)
|
confirmPending(text, queued: false, local: true)
|
||||||
pendingIds += 1
|
pendingIds += 1
|
||||||
let images = (p["images"] as? [(any Sendable)?] ?? []).map { jsString($0) }
|
let images = (p["images"] as? [(any Sendable)?] ?? []).map { jsString($0) }
|
||||||
pending.append(PendingSend(id: pendingIds, text: text, images: [], imageIds: images, local: false, queued: true, md: trimmed(text).isEmpty ? [] : parseMarkdown(text)))
|
pending.append(PendingSend(id: pendingIds, text: text, images: [], imageIds: images, local: false, queued: true, md: trimmed(text).isEmpty ? [] : parseMarkdown(text), inputId: int(p, "id")))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ev.kind == "user.unqueued" {
|
||||||
|
if sink == nil, let id = int(p, "id") {
|
||||||
|
withdrawn(id)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user