Inline question choices, compact one-click inbox cards, drop snooze

This commit is contained in:
Ian Gulliver
2026-10-14 23:34:39 -07:00
parent 4dc86e5479
commit c7cfad88a1
30 changed files with 1022 additions and 647 deletions
+33 -38
View File
@@ -88,11 +88,10 @@ func (s *Store) GetInboxItem(ctx context.Context, id string) (api.InboxItem, err
}
type InboxFilter struct {
ProjectID string
TaskID string
SessionID string
State api.InboxState
IncludeSnoozed bool
ProjectID string
TaskID string
SessionID string
State api.InboxState
}
func (s *Store) ListInbox(ctx context.Context, f InboxFilter) ([]api.InboxItem, error) {
@@ -111,10 +110,6 @@ func (s *Store) ListInbox(ctx context.Context, f InboxFilter) ([]api.InboxItem,
where = append(where, "agent_session_id = ?")
args = append(args, f.SessionID)
}
if !f.IncludeSnoozed {
where = append(where, "(snoozed_until IS NULL OR snoozed_until <= ?)")
args = append(args, now())
}
rows, err := s.db.QueryContext(ctx, `SELECT id FROM inbox_items WHERE `+strings.Join(where, " AND ")+` ORDER BY created_at`, args...)
if err != nil {
@@ -162,21 +157,26 @@ func (s *Store) Respond(ctx context.Context, actor string, resolutions []Resolut
return err
}
var options *string
if resp.Options != nil {
b, err := json.Marshal(*resp.Options)
if err != nil {
return err
}
s := string(b)
options = &s
}
_, err = tx.ExecContext(ctx, `
INSERT INTO inbox_responses (id, item_id, user_login, type, option, text, attachments, snooze_until, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, resp.Id, resp.ItemId, resp.User, string(resp.Type), resp.Option, resp.Text, string(attachments),
formatTimePtr(resp.SnoozeUntil), formatTime(resp.CreatedAt))
INSERT INTO inbox_responses (id, item_id, user_login, type, options, text, attachments, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, resp.Id, resp.ItemId, resp.User, string(resp.Type), options, resp.Text, string(attachments), formatTime(resp.CreatedAt))
if err != nil {
return err
}
if r.State == api.Open {
_, err = tx.ExecContext(ctx, `UPDATE inbox_items SET snoozed_until = ? WHERE id = ?`, formatTimePtr(resp.SnoozeUntil), resp.ItemId)
} else {
_, err = tx.ExecContext(ctx, `UPDATE inbox_items SET state = ?, resolved_by = ?, resolved_at = ?, snoozed_until = NULL WHERE id = ?`,
string(r.State), actor, now(), resp.ItemId)
}
_, err = tx.ExecContext(ctx, `UPDATE inbox_items SET state = ?, resolved_by = ?, resolved_at = ? WHERE id = ?`,
string(r.State), actor, now(), resp.ItemId)
if err != nil {
return err
}
@@ -194,7 +194,7 @@ var ErrNotOpen = errors.New("inbox item is not open")
func (s *Store) WithdrawItem(ctx context.Context, id string) error {
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
res, err := tx.ExecContext(ctx, `UPDATE inbox_items SET state = ?, resolved_at = ?, snoozed_until = NULL WHERE id = ? AND state = ?`,
res, err := tx.ExecContext(ctx, `UPDATE inbox_items SET state = ?, resolved_at = ? WHERE id = ? AND state = ?`,
string(api.Dismissed), now(), id, string(api.Open))
if err != nil {
return err
@@ -228,7 +228,7 @@ func dismissTaskInbox(ctx context.Context, tx *sql.Tx, emit emitFunc, taskID, ac
rows.Close()
for _, id := range ids {
_, err = tx.ExecContext(ctx, `UPDATE inbox_items SET state = ?, resolved_by = ?, resolved_at = ?, snoozed_until = NULL WHERE id = ?`,
_, err = tx.ExecContext(ctx, `UPDATE inbox_items SET state = ?, resolved_by = ?, resolved_at = ? WHERE id = ?`,
string(api.Dismissed), actor, now(), id)
if err != nil {
return err
@@ -324,16 +324,15 @@ func getItem(ctx context.Context, q querier, id string) (api.InboxItem, error) {
payload := ""
state := ""
createdAt := ""
snoozed := sql.NullString{}
resolvedBy := sql.NullString{}
resolvedAt := sql.NullString{}
err := q.QueryRowContext(ctx, `
SELECT id, project_id, task_id, agent_session_id, thread_id, kind, priority, blocking, payload, state,
snoozed_until, created_at, resolved_by, resolved_at
created_at, resolved_by, resolved_at
FROM inbox_items WHERE id = ?
`, id).Scan(&item.Id, &item.ProjectId, &item.TaskId, &item.AgentSessionId, &item.ThreadId, &kind, &priority, &item.Blocking,
&payload, &state, &snoozed, &createdAt, &resolvedBy, &resolvedAt)
&payload, &state, &createdAt, &resolvedBy, &resolvedAt)
if err != nil {
return item, err
}
@@ -349,10 +348,6 @@ func getItem(ctx context.Context, q querier, id string) (api.InboxItem, error) {
if err != nil {
return item, err
}
item.SnoozedUntil, err = parseTimePtr(snoozed)
if err != nil {
return item, err
}
item.ResolvedAt, err = parseTimePtr(resolvedAt)
if err != nil {
return item, err
@@ -367,7 +362,7 @@ func getItem(ctx context.Context, q querier, id string) (api.InboxItem, error) {
func getResponses(ctx context.Context, q querier, itemID string) ([]api.InboxResponse, error) {
rows, err := q.QueryContext(ctx, `
SELECT id, item_id, user_login, type, option, text, attachments, snooze_until, created_at, delivered_at
SELECT id, item_id, user_login, type, options, text, attachments, created_at, delivered_at
FROM inbox_responses WHERE item_id = ? ORDER BY created_at
`, itemID)
if err != nil {
@@ -379,21 +374,25 @@ func getResponses(ctx context.Context, q querier, itemID string) ([]api.InboxRes
for rows.Next() {
r := api.InboxResponse{}
typ := ""
option := sql.NullString{}
options := sql.NullString{}
text := sql.NullString{}
attachments := ""
snooze := sql.NullString{}
createdAt := ""
delivered := sql.NullString{}
err = rows.Scan(&r.Id, &r.ItemId, &r.User, &typ, &option, &text, &attachments, &snooze, &createdAt, &delivered)
err = rows.Scan(&r.Id, &r.ItemId, &r.User, &typ, &options, &text, &attachments, &createdAt, &delivered)
if err != nil {
return nil, err
}
r.Type = api.InboxResponseType(typ)
if option.Valid {
r.Option = &option.String
if options.Valid {
chosen := []string{}
err = json.Unmarshal([]byte(options.String), &chosen)
if err != nil {
return nil, err
}
r.Options = &chosen
}
if text.Valid {
r.Text = &text.String
@@ -402,10 +401,6 @@ func getResponses(ctx context.Context, q querier, itemID string) ([]api.InboxRes
if err != nil {
return nil, err
}
r.SnoozeUntil, err = parseTimePtr(snooze)
if err != nil {
return nil, err
}
r.CreatedAt, err = parseTime(createdAt)
if err != nil {
return nil, err
+53
View File
@@ -194,6 +194,59 @@ func TestBlobsMigrationOverPositionalCopy(t *testing.T) {
}
}
func TestInlineQuestionChoicesMigration(t *testing.T) {
db := openTestDB(t, filepath.Join(t.TempDir(), "byre.db"))
i := slices.IndexFunc(migrations, func(m migration) bool { return m.name == "inline-question-choices" })
err := migrate(db, migrations[:i])
if err != nil {
t.Fatalf("migrate: %v", err)
}
insertReport(t, db, "r1", `{"text":"x"}`)
_, err = db.Exec(`
INSERT INTO inbox_items (id, project_id, task_id, agent_session_id, thread_id, kind, priority, blocking, payload, state, created_at)
VALUES ('q1', 'p1', 't1', 's1', 'q1', 'question', 0, 1,
'{"context":"Pick a color.","question":"Which?","why_blocked":"w","options":["red","blue"],"recommended_option":"blue","attachments":[]}', 'answered', 'x');
INSERT INTO inbox_responses (id, item_id, user_login, type, option, text, attachments, created_at) VALUES ('a1', 'q1', 'alice', 'option', 'red', NULL, '[]', 'x');
INSERT INTO inbox_responses (id, item_id, user_login, type, option, text, attachments, created_at) VALUES ('a2', 'q1', 'alice', 'other', NULL, 'green', '[]', 'x');
`)
if err != nil {
t.Fatalf("insert question: %v", err)
}
err = migrate(db, migrations)
if err != nil {
t.Fatalf("migrate: %v", err)
}
payload := reportPayload(t, db, "q1")
want := `{"context":"Pick a color.\n\n- ( ) red\n- (*) blue","question":"Which?","why_blocked":"w","options":["red","blue"],"attachments":[],"multiple":false,"recommended":["blue"]}`
if payload != want {
t.Fatalf("question payload\n got %s\nwant %s", payload, want)
}
if reportPayload(t, db, "r1") != `{"text":"x"}` {
t.Fatalf("report payload changed")
}
options := map[string]sql.NullString{}
rows, err := db.Query(`SELECT id, options FROM inbox_responses`)
if err != nil {
t.Fatalf("responses: %v", err)
}
defer rows.Close()
for rows.Next() {
id := ""
o := sql.NullString{}
err = rows.Scan(&id, &o)
if err != nil {
t.Fatalf("scan: %v", err)
}
options[id] = o
}
if options["a1"].String != `["red"]` || options["a2"].Valid {
t.Fatalf("response options %+v", options)
}
}
func TestFlattenReportPayloadsRunsTwice(t *testing.T) {
db := openTestDB(t, filepath.Join(t.TempDir(), "byre.db"))
err := migrate(db, migrations)
+14
View File
@@ -192,6 +192,20 @@ CREATE TABLE snapshots (
created_at TEXT NOT NULL,
UNIQUE (task_id, n)
);
`}, {"inline-question-choices", `
ALTER TABLE inbox_responses ADD COLUMN options TEXT;
UPDATE inbox_responses SET options = json_array(option) WHERE option IS NOT NULL;
ALTER TABLE inbox_responses DROP COLUMN option;
UPDATE inbox_items SET payload = json_set(json_remove(payload, '$.recommended_option'),
'$.context', json_extract(payload, '$.context') || char(10) || char(10) || (
SELECT group_concat(CASE WHEN o.value = json_extract(inbox_items.payload, '$.recommended_option') THEN '- (*) ' ELSE '- ( ) ' END || o.value, char(10))
FROM json_each(inbox_items.payload, '$.options') o),
'$.multiple', json('false'),
'$.recommended', json_array(json_extract(payload, '$.recommended_option')))
WHERE kind = 'question' AND json_type(payload, '$.recommended_option') IS NOT NULL;
`}, {"unsnooze-inbox-items", `
UPDATE inbox_items SET snoozed_until = NULL WHERE snoozed_until IS NOT NULL;
`}}
type Store struct {