Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/hub/lib/composer/outbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1685,6 +1685,14 @@ test("legacy preferences gain independent module layouts without accepting off-s
expect(widgetPreferencesSchema.safeParse({ topModules: ["../../invalid"] }).success).toBe(false);
});

test("send-and-close timing defaults to 2.5 seconds and can keep the panel open", () => {
expect(widgetPreferencesSchema.parse({}).sendCloseSeconds).toBe(2.5);
expect(widgetPreferencesSchema.parse({ sendCloseSeconds: 0 }).sendCloseSeconds).toBe(0);
expect(widgetPreferencesSchema.parse({ sendCloseSeconds: 10 }).sendCloseSeconds).toBe(10);
expect(widgetPreferencesSchema.safeParse({ sendCloseSeconds: -1 }).success).toBe(false);
expect(widgetPreferencesSchema.safeParse({ sendCloseSeconds: 61 }).success).toBe(false);
});

test("an open TODO does not mark its session as waiting for an answer", async () => {
const directory = await root();
const file = join(directory, "decisions.jsonl");
Expand Down
9 changes: 8 additions & 1 deletion src/hub/lib/widget/staged.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { sendAnsweredDecisions } from "@app/question/lib/decisions/send";
import { type DecisionRecord, kindOf, readDecisions, updateDecisions } from "@app/question/lib/decisions/store";
import { answerInboxDecisions, type InboxAnswerResult } from "@app/question/lib/inbox/answer";
import { waitingBlock } from "@app/question/lib/inbox/load";
import { logger } from "@genesiscz/utils/logger";

/** Where a session's answers go: the widget's target for it (the decision row alone has no source home). */
export interface StagedTarget {
Expand Down Expand Up @@ -54,6 +55,8 @@ export async function sendStagedDecisions({
const row = staged.find((entry) => entry.id === id);

if (!row) {
// Sent, discarded or answered meanwhile: nothing to send for it, and the reply must not read as a send.
logger.warn({ id }, "widget send-staged: decision is no longer staged");
continue;
}

Expand Down Expand Up @@ -85,13 +88,17 @@ export async function sendStagedDecisions({
{ ...files, block: waitingBlock, deliver }
);
result.sessions.push({ session, numbers, result: sent });

logger.info(
{ session, numbers, delivered: sent.delivered, provider },
sent.delivered ? "widget send-staged: delivered" : "widget send-staged: queued for the next prompt"
);
if (sent.delivered) {
result.sent += rows.length;
} else {
result.queued += rows.length;
}
} catch (error) {
logger.warn({ session, numbers, error }, "widget send-staged: failed");
result.sessions.push({ session, numbers, error: error instanceof Error ? error.message : String(error) });
result.failed += rows.length;
}
Expand Down
6 changes: 6 additions & 0 deletions src/hub/lib/widget/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ export const widgetPreferencesSchema = z.object({
sidePosition: z.number().min(0).max(1).default(0.5),
hoverPreviews: z.boolean().default(true),
glassEffect: z.boolean().default(true),
/** The widget panel's own background: "dark" (solid near-black), "glass", or "theme" (the app-wide theme). */
panelBackground: z.enum(["dark", "glass", "theme"]).default("dark"),
/** The hover list's rows show their waiting question under the session. */
previewQuestion: z.boolean().default(true),
display: z.string().default(""),
providers: z.array(z.enum(["claude", "codex", "grok", "unknown"])).default([]),
quietSeconds: z.number().int().min(3).max(300).default(15),
Expand Down Expand Up @@ -85,6 +89,8 @@ export const widgetPreferencesSchema = z.object({
decisionClick: z.enum(["stage", "send"]).default("stage"),
/** Leaving the inbox with staged decisions: ask what to do ("ask"), keep them staged, or send them. */
stagedOnLeave: z.enum(["ask", "keep", "send"]).default("ask"),
/** Seconds before a successful send-and-close folds the panel; 0 keeps it open. */
sendCloseSeconds: z.number().min(0).max(60).default(2.5),
/** The inbox of every session: entries grouped under their session ("session"), or one list. */
inboxGrouping: z.enum(["session", "list"]).default("session"),
/** The filter a session's inbox opens with: every entry ("all"), the inbox entries, or the decisions. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,9 @@ public struct LiveWidgetView: View {
expanded: model.expanded == edge,
animationsActive: model.placement == "both" || (model.placement == "top" ? edge == .top : edge != .top),
cutoutWidth: cutout, compactHeight: compactHeight,
draft: Binding(get: { model.draft.text }, set: model.setText),
draft: model.composerText(),
actions: AgentWidgetActions(
expand: { model.open(edge) }, collapse: model.collapse,
expand: { model.open(edge) }, collapse: { model.collapse() },
select: { model.select($0, edge: edge) }, choose: { model.choose(option: $0) },
submit: { model.submit() }, settings: { model.showSettings?() }, next: { model.next() }),
liveContent: AnyView(content)
Expand Down Expand Up @@ -209,23 +209,14 @@ public struct LiveWidgetView: View {
if section == "Inbox", model.stagedView == nil { WidgetStagedChip(model: model) }
ScrollViewReader { reader in
ScrollView {
LazyVStack(alignment: .leading, spacing: 14) {
if model.stagedView != nil {
WidgetStagedList(model: model)
} else if section == "Inbox" {
inbox
} else if section == "Sessions" {
// Only this list reports its drawn order for Up and Down; the chooser popover does not.
WidgetSessionBrowser(model: model, reportsOrder: true)
} else if section == "Changes" {
changes
} else {
conversation
}
// The inbox is eager: a session holds tens of cards, and a lazy stack's estimated height
// under an open card made the scroll offset and the document chase each other without end
// (the widget hung at 99% CPU on a session's Decisions, Martin, 2026-10-25).
Group {
if section == "Inbox" {
ForEach(model.outgoing) { message in
outgoing(message).id("outgoing-" + message.id)
}
VStack(alignment: .leading, spacing: 14) { sectionList }
} else {
LazyVStack(alignment: .leading, spacing: 14) { sectionList }
}
}.frame(maxWidth: .infinity, alignment: .leading).padding(.vertical, 2)
.scrollOverflowContent()
Expand Down Expand Up @@ -286,6 +277,27 @@ public struct LiveWidgetView: View {
}
}

/// The selected section's rows inside the pane's scroll view.
@ViewBuilder private var sectionList: some View {
if model.stagedView != nil {
WidgetStagedList(model: model)
} else if section == "Inbox" {
inbox
} else if section == "Sessions" {
// Only this list reports its drawn order for Up and Down; the chooser popover does not.
WidgetSessionBrowser(model: model, reportsOrder: true)
} else if section == "Changes" {
changes
} else {
conversation
}
if section == "Inbox" {
ForEach(model.outgoing) { message in
outgoing(message).id("outgoing-" + message.id)
}
}
}

/// The pane needs its chrome plus the whole scroll document; without a list (connecting, nothing selected) a
/// short fixed height is enough.
/// The three heights change in separate callbacks of one layout pass; read them together after it.
Expand Down Expand Up @@ -366,7 +378,7 @@ public struct LiveWidgetView: View {
model.showSettings?()
}
.accessibilityLabel("Widget settings")
IconButton(systemName: "xmark", tooltip: WidgetKeymap.hint("Collapse", WidgetKeymap.collapse), action: model.requestCollapse)
IconButton(systemName: "xmark", tooltip: WidgetKeymap.hint("Collapse", WidgetKeymap.collapse), action: { model.requestCollapse() })
.accessibilityLabel("Collapse widget")
}
}
Expand Down Expand Up @@ -1212,7 +1224,7 @@ public struct LiveWidgetView: View {
.frame(maxWidth: .infinity, minHeight: composerFieldHeight, maxHeight: composerFieldHeight,
alignment: .topLeading)
} else {
TextField(placeholder, text: Binding(get: { model.draft.text }, set: model.setText), axis: .vertical)
TextField(placeholder, text: model.composerText(ownAnswer: ownAnswer?.id), axis: .vertical)
.textFieldStyle(.plain).font(.system(size: 12)).lineLimit(1...4).focused($editing)
.onSubmit { model.send() }
.accessibilityLabel(ownAnswer == nil ? "Reply to selected agent" : "Your own answer")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,24 @@ struct AgentWidgetPreview: View {
selection: Binding(get: { model.previewFilter }, set: { model.setPreview(filter: $0) }),
minSegmentWidth: 70, identifier: "widget.preview.filter")
Spacer(minLength: 4)
IconButton(systemName: "arrow.up.arrow.down", tooltip: sortTooltip, size: 10.5) {
// The order is named beside its icon, so reading it needs no hover (Martin, 2026-10-25).
Button {
let sorts = WidgetPreviewChoices.sorts.map(\.value)
let index = sorts.firstIndex(of: model.previewSort) ?? 0
model.setPreview(sort: sorts[(index + 1) % sorts.count])
}.accessibilityLabel("Order: " + WidgetPreviewChoices.title(sort: model.previewSort))
} label: {
HStack(spacing: 3) {
Image(systemName: "arrow.up.arrow.down").font(.system(size: 10.5))
Text(WidgetPreviewChoices.shortTitle(sort: model.previewSort)).font(.system(size: 11))
}
.foregroundStyle(.secondary)
.padding(.horizontal, 5).frame(minHeight: 22)
.contentShape(Rectangle())
}
.buttonStyle(.genHoverPlain())
.instantTooltip(sortTooltip)
.accessibilityLabel("Order: " + WidgetPreviewChoices.title(sort: model.previewSort))
.accessibilityHint("Click for the next order")
IconButton(systemName: "slider.horizontal.3", tooltip: "Choose what this list shows and its order",
size: 10.5) {
model.showSettingsPage?("widgets.sessions#inbox-preview")
Expand Down Expand Up @@ -182,8 +195,20 @@ struct AgentWidgetPreview: View {
return "Order: \(sorts[index].title). Click for \(sorts[(index + 1) % sorts.count].title)."
}

static func pendingQuestion(_ item: WidgetInboxItem?, enabled: Bool) -> String? {
guard enabled, let item, let title = item.title, !title.isEmpty else { return nil }
return WidgetAlerts.kicker(item) + " · " + title
}

static func rowAccessibilityLabel(_ session: WidgetSession, question: String?) -> String {
let label = "Open " + session.title + ", " + session.visualStatus.label
return question.map { label + ", " + $0 } ?? label
}

private func row(_ session: WidgetSession) -> some View {
WidgetHoverRow { hovering in HStack(spacing: 4) {
let question = Self.pendingQuestion(model.inboxFor(session.key)?.pendingItem,
enabled: model.snapshot?.state.preferences.previewQuestion ?? true)
return WidgetHoverRow { hovering in HStack(spacing: 4) {
Button {
model.openInboxNotification(on: surface, key: session.key)
} label: {
Expand All @@ -198,6 +223,12 @@ struct AgentWidgetPreview: View {
Text(session.visualStatus.label).lineLimit(1)
}.font(.system(size: 11)).foregroundStyle(.white.opacity(0.6))
WidgetSessionTimes(session: session, inbox: model.inboxFor(session.key))
// The waiting question itself, so an alert that folded can be found again here (Martin,
// 2026-10-25: "I can't seem to find that same stuff anywhere").
if let question {
Text(question)
.font(.system(size: 11)).foregroundStyle(Color.orange.opacity(0.9)).lineLimit(1)
}
}
Spacer(minLength: 2)
if let inbox = model.inboxFor(session.key), inbox.unread + inbox.needsAnswer > 0 {
Expand All @@ -214,7 +245,7 @@ struct AgentWidgetPreview: View {
.contentShape(Rectangle())
}
.buttonStyle(RowButtonStyle(cornerRadius: 10))
.accessibilityLabel("Open " + session.title + ", " + session.visualStatus.label)
.accessibilityLabel(Self.rowAccessibilityLabel(session, question: question))
WidgetCmuxButton(model: model, session: session, live: hovering)
} }
.padding(.trailing, 4)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ struct WidgetDecisionOutcome: View {
}
if !picked.isEmpty || !(card.answer ?? "").isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text(card.answeredVia == "agent" ? "Answer recorded by the agent" : "Your answer")
Text(card.answeredVia == "agent" ? "Answer recorded by the agent"
: card.answeredVia == "automatic_heuristic" ? "Answer read from your chat" : "Your answer")
.font(.system(size: 10, weight: .semibold)).foregroundStyle(.secondary)
ForEach(picked, id: \.id) { choice in
HStack(alignment: .top, spacing: 6) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,13 @@ private struct WidgetGeneralSettings: View {
isOn: boolean("joinedEdges", prefs?.joinedEdges ?? true))
}
NativeSettingsCard("Appearance") {
NativeSettingsToggle(
"Glass effect", detail: "Use your selected theme for widget surfaces.",
isOn: boolean("glassEffect", prefs?.glassEffect ?? true))
NativeSettingsSegmented(
"Panel background",
detail: "Dark is a solid near-black panel. Glass shows the desktop through it. Same as the app follows the theme in General, which other windows can change.",
identifier: "widget.panelBackground",
options: [NativeSettingsOption("dark", "Dark"), NativeSettingsOption("glass", "Glass"),
NativeSettingsOption("theme", "Same as the app")],
selection: string("panelBackground", prefs?.panelBackground ?? "dark"))
Divider()
NativeSettingsToggle(
"Reduce motion", detail: "Shared with General: keeps every native window and widget still.",
Expand All @@ -221,6 +225,11 @@ private struct WidgetGeneralSettings: View {
isOn: $appearance.reduceTransparency)
}
NativeSettingsCard("Inbox behavior") {
NativeSettingsToggle(
"Show the waiting question in the list",
detail: "Each session in the hover list shows its open decision under its name, so an alert that closed can be found again.",
isOn: boolean("previewQuestion", prefs?.previewQuestion ?? true))
Divider()
NativeSettingsRow(
"Collapse when quiet", detail: "A draft, recording or active agent keeps the inbox open."
) {
Expand Down Expand Up @@ -263,6 +272,17 @@ private struct WidgetGeneralSettings: View {
}.labelsHidden().pickerStyle(.segmented).frame(width: 250, alignment: .trailing)
}
Divider()
NativeSettingsRow("Close after send", detail: "When closing sends staged decisions, keep the delivered note readable for this long. Any new typing, pick or session selection keeps the panel open.") {
Picker("Close after send", selection: Binding(
get: { prefs?.sendCloseSeconds ?? 2.5 },
set: { patch(["sendCloseSeconds": .number($0)]) })) {
Text("Keep open").tag(0.0)
ForEach(Array(Set([2.5, 5, 10, prefs?.sendCloseSeconds ?? 2.5])).filter { $0 > 0 }.sorted(), id: \.self) { seconds in
Text("\(seconds.formatted()) seconds").tag(seconds)
}
}.labelsHidden().accessibilityIdentifier("widget.sendCloseSeconds")
}
Divider()
NativeSettingsRow("Inbox of every session", detail: "The inbox button's list: grouped under each session, or one list.") {
Picker("Inbox of every session", selection: string("inboxGrouping", prefs?.inboxGrouping ?? "session")) {
Text("By session").tag("session")
Expand Down Expand Up @@ -996,4 +1016,13 @@ enum WidgetPreviewChoices {
]

static func title(sort: String) -> String { sorts.first { $0.value == sort }?.title ?? sort }
/// The one-word name the inbox header shows beside the sort button.
static func shortTitle(sort: String) -> String {
switch sort {
case "attention": return "Waiting"
case "recent": return "Newest"
case "project": return "Project"
default: return title(sort: sort)
}
}
}
Loading
Loading