mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-25 16:39:33 +02:00
Compare commits
1 Commits
agent/lamb
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
117fb78013 |
@@ -111,22 +111,6 @@ function createWindow(): void {
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
// Prevent Cmd+R / Ctrl+R / Shift+Cmd+R / Shift+Ctrl+R / F5 from
|
||||
// reloading the page. In a desktop app an accidental reload destroys
|
||||
// in-memory state (tabs, drafts, WS connections) with no URL bar to
|
||||
// navigate back. DevTools refresh (via the DevTools UI) still works.
|
||||
mainWindow.webContents.on("before-input-event", (_event, input) => {
|
||||
if (input.type !== "keyDown") return;
|
||||
const cmdOrCtrl =
|
||||
process.platform === "darwin" ? input.meta : input.control;
|
||||
if (
|
||||
(cmdOrCtrl && input.key.toLowerCase() === "r") ||
|
||||
input.key === "F5"
|
||||
) {
|
||||
_event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
installContextMenu(mainWindow.webContents);
|
||||
|
||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
|
||||
@@ -17,7 +17,6 @@ import type {
|
||||
AgentRunCount,
|
||||
AgentRuntime,
|
||||
InboxItem,
|
||||
NotificationPreference,
|
||||
IssueSubscriber,
|
||||
Comment,
|
||||
Reaction,
|
||||
@@ -784,19 +783,6 @@ export class ApiClient {
|
||||
return this.fetch("/api/inbox/archive-completed", { method: "POST" });
|
||||
}
|
||||
|
||||
async listNotificationPreferences(): Promise<NotificationPreference[]> {
|
||||
return this.fetch("/api/inbox/preferences");
|
||||
}
|
||||
|
||||
async updateNotificationPreferences(
|
||||
preferences: NotificationPreference[],
|
||||
): Promise<NotificationPreference[]> {
|
||||
return this.fetch("/api/inbox/preferences", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ preferences }),
|
||||
});
|
||||
}
|
||||
|
||||
// App Config
|
||||
async getConfig(): Promise<{
|
||||
cdn_domain: string;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import { inboxKeys } from "./queries";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import type { InboxItem, NotificationPreference } from "../types";
|
||||
import type { InboxItem } from "../types";
|
||||
|
||||
export function useMarkInboxRead() {
|
||||
const qc = useQueryClient();
|
||||
@@ -111,31 +111,3 @@ export function useArchiveCompletedInbox() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateNotificationPreferences() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: (preferences: NotificationPreference[]) =>
|
||||
api.updateNotificationPreferences(preferences),
|
||||
onMutate: async (preferences) => {
|
||||
await qc.cancelQueries({ queryKey: inboxKeys.preferences(wsId) });
|
||||
const prev = qc.getQueryData<NotificationPreference[]>(inboxKeys.preferences(wsId));
|
||||
qc.setQueryData<NotificationPreference[]>(inboxKeys.preferences(wsId), (old) => {
|
||||
if (!old) return preferences;
|
||||
const map = new Map(old.map((p) => [p.notification_type, p]));
|
||||
for (const p of preferences) {
|
||||
map.set(p.notification_type, p);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
});
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(inboxKeys.preferences(wsId), ctx.prev);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.preferences(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { InboxItem } from "../types";
|
||||
export const inboxKeys = {
|
||||
all: (wsId: string) => ["inbox", wsId] as const,
|
||||
list: (wsId: string) => [...inboxKeys.all(wsId), "list"] as const,
|
||||
preferences: (wsId: string) => [...inboxKeys.all(wsId), "preferences"] as const,
|
||||
};
|
||||
|
||||
export function inboxListOptions(wsId: string) {
|
||||
@@ -58,10 +57,3 @@ export function deduplicateInboxItems(items: InboxItem[]): InboxItem[] {
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
export function notificationPreferencesOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: inboxKeys.preferences(wsId),
|
||||
queryFn: () => api.listNotificationPreferences(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -38,8 +38,3 @@ export interface InboxItem {
|
||||
created_at: string;
|
||||
details: Record<string, string> | null;
|
||||
}
|
||||
|
||||
export interface NotificationPreference {
|
||||
notification_type: InboxItemType;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export type {
|
||||
IssueUsageSummary,
|
||||
} from "./agent";
|
||||
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser, Invitation } from "./workspace";
|
||||
export type { InboxItem, InboxSeverity, InboxItemType, NotificationPreference } from "./inbox";
|
||||
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
|
||||
export type { Comment, CommentType, CommentAuthorType, Reaction } from "./comment";
|
||||
export type { Label, CreateLabelRequest, UpdateLabelRequest, ListLabelsResponse, IssueLabelsResponse } from "./label";
|
||||
export type { TimelineEntry, AssigneeFrequencyEntry } from "./activity";
|
||||
|
||||
@@ -44,9 +44,7 @@ import {
|
||||
} from "./trigger-config";
|
||||
import type { TriggerConfig } from "./trigger-config";
|
||||
import type { AutopilotExecutionMode, AutopilotRun, AutopilotTrigger } from "@multica/core/types";
|
||||
import type { AgentTask } from "@multica/core/types/agent";
|
||||
import { ReadonlyContent } from "../../editor";
|
||||
import { TranscriptButton } from "../../common/task-transcript";
|
||||
import { AutopilotDialog } from "./autopilot-dialog";
|
||||
|
||||
function formatDate(date: string): string {
|
||||
@@ -65,34 +63,11 @@ const RUN_STATUS_CONFIG: Record<string, { label: string; color: string; icon: ty
|
||||
failed: { label: "Failed", color: "text-destructive", icon: XCircle },
|
||||
};
|
||||
|
||||
function RunRow({ run, agentId, agentName }: { run: AutopilotRun; agentId: string; agentName: string }) {
|
||||
function RunRow({ run }: { run: AutopilotRun }) {
|
||||
const wsPaths = useWorkspacePaths();
|
||||
const cfg = (RUN_STATUS_CONFIG[run.status] ?? RUN_STATUS_CONFIG["issue_created"])!;
|
||||
const StatusIcon = cfg.icon;
|
||||
|
||||
// For runs with a task_id (run_only mode), build a minimal AgentTask so
|
||||
// TranscriptButton can lazy-load the execution transcript.
|
||||
const syntheticTask: AgentTask | null = run.task_id
|
||||
? {
|
||||
id: run.task_id,
|
||||
agent_id: agentId,
|
||||
runtime_id: "",
|
||||
issue_id: "",
|
||||
status:
|
||||
run.status === "running" ? "running" :
|
||||
run.status === "completed" ? "completed" :
|
||||
run.status === "failed" ? "failed" :
|
||||
"queued",
|
||||
priority: 0,
|
||||
dispatched_at: null,
|
||||
started_at: run.triggered_at || null,
|
||||
completed_at: run.completed_at || null,
|
||||
result: null,
|
||||
error: run.failure_reason || null,
|
||||
created_at: run.created_at,
|
||||
}
|
||||
: null;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<StatusIcon className={cn("h-4 w-4 shrink-0", cfg.color, cfg.spin && "animate-spin")} />
|
||||
@@ -108,14 +83,6 @@ function RunRow({ run, agentId, agentName }: { run: AutopilotRun; agentId: strin
|
||||
<span className="w-32 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
|
||||
{formatDate(run.triggered_at || run.created_at)}
|
||||
</span>
|
||||
{syntheticTask && !run.issue_id && (
|
||||
<TranscriptButton
|
||||
task={syntheticTask}
|
||||
agentName={agentName}
|
||||
isLive={run.status === "running"}
|
||||
title="View execution log"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -471,7 +438,7 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
) : (
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
{runs.map((run) => (
|
||||
<RunRow key={run.id} run={run} agentId={autopilot.assignee_id} agentName={getActorName("agent", autopilot.assignee_id)} />
|
||||
<RunRow key={run.id} run={run} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { notificationPreferencesOptions } from "@multica/core/inbox/queries";
|
||||
import { useUpdateNotificationPreferences } from "@multica/core/inbox/mutations";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
MessageSquare,
|
||||
UserCheck,
|
||||
AtSign,
|
||||
RefreshCw,
|
||||
ArrowUpDown,
|
||||
CalendarDays,
|
||||
Heart,
|
||||
AlertTriangle,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import type { InboxItemType, NotificationPreference } from "@multica/core/types";
|
||||
import { Switch } from "@multica/ui/components/ui/switch";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogTrigger,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
interface NotificationCategory {
|
||||
label: string;
|
||||
description: string;
|
||||
types: InboxItemType[];
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const categories: NotificationCategory[] = [
|
||||
{
|
||||
label: "Status changes",
|
||||
description: "Issue status transitions (e.g. Todo → In Progress)",
|
||||
types: ["status_changed"],
|
||||
icon: RefreshCw,
|
||||
},
|
||||
{
|
||||
label: "Comments",
|
||||
description: "New comments on subscribed issues",
|
||||
types: ["new_comment"],
|
||||
icon: MessageSquare,
|
||||
},
|
||||
{
|
||||
label: "Assignments",
|
||||
description: "Issue assignment and unassignment",
|
||||
types: ["issue_assigned", "unassigned", "assignee_changed"],
|
||||
icon: UserCheck,
|
||||
},
|
||||
{
|
||||
label: "Mentions",
|
||||
description: "When you are @mentioned",
|
||||
types: ["mentioned"],
|
||||
icon: AtSign,
|
||||
},
|
||||
{
|
||||
label: "Priority changes",
|
||||
description: "Issue priority updates",
|
||||
types: ["priority_changed"],
|
||||
icon: ArrowUpDown,
|
||||
},
|
||||
{
|
||||
label: "Due date changes",
|
||||
description: "Issue due date updates",
|
||||
types: ["due_date_changed"],
|
||||
icon: CalendarDays,
|
||||
},
|
||||
{
|
||||
label: "Reactions",
|
||||
description: "Reactions to your issues or comments",
|
||||
types: ["reaction_added"],
|
||||
icon: Heart,
|
||||
},
|
||||
{
|
||||
label: "Agent events",
|
||||
description: "Agent task failures and blocks",
|
||||
types: ["task_failed", "agent_blocked", "task_completed", "agent_completed"],
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
{
|
||||
label: "Quick create",
|
||||
description: "Quick create success and failure results",
|
||||
types: ["quick_create_done", "quick_create_failed"],
|
||||
icon: Sparkles,
|
||||
},
|
||||
];
|
||||
|
||||
function isCategoryEnabled(
|
||||
prefs: NotificationPreference[],
|
||||
types: InboxItemType[],
|
||||
): boolean {
|
||||
const prefMap = new Map(prefs.map((p) => [p.notification_type, p.enabled]));
|
||||
return types.every((t) => prefMap.get(t) !== false);
|
||||
}
|
||||
|
||||
export function InboxNotificationSettings({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: prefs = [] } = useQuery(notificationPreferencesOptions(wsId));
|
||||
const updateMutation = useUpdateNotificationPreferences();
|
||||
|
||||
const handleToggle = (category: NotificationCategory, enabled: boolean) => {
|
||||
const updates: NotificationPreference[] = category.types.map((t) => ({
|
||||
notification_type: t,
|
||||
enabled,
|
||||
}));
|
||||
updateMutation.mutate(updates, {
|
||||
onError: () => toast.error("Failed to update notification settings"),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger render={<>{children}</>} />
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Notification settings</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose which notification types appear in your Inbox.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-1 -mx-4 max-h-[60vh] overflow-y-auto px-4">
|
||||
{categories.map((category) => {
|
||||
const enabled = isCategoryEnabled(prefs, category.types);
|
||||
return (
|
||||
<label
|
||||
key={category.label}
|
||||
className="flex items-center justify-between gap-3 rounded-md px-2 py-2.5 hover:bg-muted/50 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<category.icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-medium leading-none">
|
||||
{category.label}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground leading-snug">
|
||||
{category.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleToggle(category, checked)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
BookCheck,
|
||||
ListChecks,
|
||||
ArrowLeft,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import type { InboxItem } from "@multica/core/types";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
@@ -54,7 +53,6 @@ import { PageHeader } from "../../layout/page-header";
|
||||
import { InboxListItem, timeAgo } from "./inbox-list-item";
|
||||
import { typeLabels } from "./inbox-detail-label";
|
||||
import { getInboxDisplayTitle } from "./inbox-display";
|
||||
import { InboxNotificationSettings } from "./inbox-notification-settings";
|
||||
|
||||
export function InboxPage() {
|
||||
const { searchParams, replace } = useNavigation();
|
||||
@@ -202,17 +200,7 @@ export function InboxPage() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<InboxNotificationSettings>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</InboxNotificationSettings>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
@@ -244,7 +232,6 @@ export function InboxPage() {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</PageHeader>
|
||||
);
|
||||
|
||||
|
||||
@@ -66,50 +66,6 @@ func parseMentions(content string) []mention {
|
||||
return result
|
||||
}
|
||||
|
||||
// isNotifEnabled checks whether a notification type is enabled for a specific
|
||||
// user in a workspace. It queries the notification_preference table; if no
|
||||
// explicit preference exists, it falls back to the default (most types default
|
||||
// to enabled; status_changed defaults to disabled).
|
||||
func isNotifEnabled(
|
||||
ctx context.Context,
|
||||
queries *db.Queries,
|
||||
workspaceID string,
|
||||
userID string,
|
||||
notifType string,
|
||||
prefCache map[string]bool,
|
||||
) bool {
|
||||
// Check the per-event cache first (populated by preloadNotifPrefs).
|
||||
if enabled, ok := prefCache[userID]; ok {
|
||||
return enabled
|
||||
}
|
||||
// No explicit preference → use default.
|
||||
return !handler.DefaultDisabledTypes[notifType]
|
||||
}
|
||||
|
||||
// preloadNotifPrefs loads all explicit preferences for a notification type in
|
||||
// a workspace into a map[userID]enabled. This avoids per-subscriber queries.
|
||||
func preloadNotifPrefs(
|
||||
ctx context.Context,
|
||||
queries *db.Queries,
|
||||
workspaceID string,
|
||||
notifType string,
|
||||
) map[string]bool {
|
||||
rows, err := queries.ListNotificationPreferencesByType(ctx, db.ListNotificationPreferencesByTypeParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
NotificationType: notifType,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("failed to load notification preferences",
|
||||
"workspace_id", workspaceID, "type", notifType, "error", err)
|
||||
return nil
|
||||
}
|
||||
m := make(map[string]bool, len(rows))
|
||||
for _, r := range rows {
|
||||
m[util.UUIDToString(r.UserID)] = r.Enabled
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// parentBubbleNotifTypes is the allowlist of inbox notification types that
|
||||
// bubble up from a sub-issue to subscribers of its parent. Other event types
|
||||
// only notify subscribers of the sub-issue itself, to keep parent watchers'
|
||||
@@ -206,9 +162,6 @@ func notifyIssueSubscribers(
|
||||
return notified
|
||||
}
|
||||
|
||||
// Preload notification preferences for this type (one query for all subscribers).
|
||||
prefCache := preloadNotifPrefs(ctx, queries, workspaceID, notifType)
|
||||
|
||||
for _, sub := range subs {
|
||||
// Only notify member-type subscribers (not agents)
|
||||
if sub.UserType != "member" {
|
||||
@@ -227,11 +180,6 @@ func notifyIssueSubscribers(
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if user has disabled this notification type
|
||||
if !isNotifEnabled(ctx, queries, workspaceID, subID, notifType, prefCache) {
|
||||
continue
|
||||
}
|
||||
|
||||
item, err := queries.CreateInboxItem(ctx, db.CreateInboxItemParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
RecipientType: "member",
|
||||
@@ -289,14 +237,6 @@ func notifyDirect(
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if recipient has disabled this notification type (members only)
|
||||
if recipientType == "member" {
|
||||
prefCache := preloadNotifPrefs(ctx, queries, workspaceID, notifType)
|
||||
if !isNotifEnabled(ctx, queries, workspaceID, recipientID, notifType, prefCache) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
item, err := queries.CreateInboxItem(ctx, db.CreateInboxItemParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
RecipientType: recipientType,
|
||||
@@ -368,17 +308,10 @@ func notifyMentionedMembers(
|
||||
}
|
||||
}
|
||||
|
||||
// Preload mention preferences
|
||||
mentionPrefCache := preloadNotifPrefs(context.Background(), queries, e.WorkspaceID, "mentioned")
|
||||
|
||||
for id := range recipientIDs {
|
||||
if id == e.ActorID || skip[id] {
|
||||
continue
|
||||
}
|
||||
// Skip if user has disabled mention notifications
|
||||
if !isNotifEnabled(context.Background(), queries, e.WorkspaceID, id, "mentioned", mentionPrefCache) {
|
||||
continue
|
||||
}
|
||||
item, err := queries.CreateInboxItem(context.Background(), db.CreateInboxItemParams{
|
||||
WorkspaceID: parseUUID(e.WorkspaceID),
|
||||
RecipientType: "member",
|
||||
|
||||
@@ -486,8 +486,6 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
|
||||
r.Post("/archive-completed", h.ArchiveCompletedInbox)
|
||||
r.Post("/{id}/read", h.MarkInboxRead)
|
||||
r.Post("/{id}/archive", h.ArchiveInboxItem)
|
||||
r.Get("/preferences", h.ListNotificationPreferences)
|
||||
r.Put("/preferences", h.UpdateNotificationPreferences)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,22 +32,21 @@ func BuildPrompt(task Task) string {
|
||||
|
||||
// buildQuickCreatePrompt constructs a prompt for quick-create tasks. The
|
||||
// user typed a single natural-language sentence in the create-issue modal;
|
||||
// the agent's job is to translate it into one `multica issue create` CLI
|
||||
// invocation, using its judgment to decide whether fetching referenced URLs
|
||||
// would produce a better issue. No issue exists yet, so the agent must NOT
|
||||
// call `multica issue get` or attempt to comment — there's nothing to read
|
||||
// or reply to.
|
||||
// the agent's only job is to translate it into one `multica issue create`
|
||||
// CLI invocation. No issue exists yet, so the agent must NOT call
|
||||
// `multica issue get` or attempt to comment — there's nothing to read or
|
||||
// reply to.
|
||||
func buildQuickCreatePrompt(task Task) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("You are running as a quick-create assistant for a Multica workspace.\n\n")
|
||||
b.WriteString("A user pressed the quick-create shortcut and typed a one-line description. There is NO existing issue. Your job is to create a well-formed issue from the user's input with a single `multica issue create` command.\n\n")
|
||||
b.WriteString("A user pressed the quick-create shortcut and typed a one-line description. There is NO existing issue. Your only job is to translate the description into a single `multica issue create` command and run it.\n\n")
|
||||
fmt.Fprintf(&b, "User input:\n> %s\n\n", task.QuickCreatePrompt)
|
||||
b.WriteString("Field rules:\n")
|
||||
b.WriteString("- title: required. A concise but semantically rich summary that lets a reader understand what the issue is about at a glance. If the user input references external resources (PRs, issues, URLs, etc.), use your judgment to decide whether fetching the resource would produce a meaningfully better title — if so, fetch it and incorporate the relevant context. For example, \"review PR #123\" is much less useful than \"Review PR #123: Refactor auth module to OAuth2\". Strip filler words but preserve key semantic information.\n")
|
||||
b.WriteString("- description: always provide a rich, self-contained description. The created issue will be picked up and executed by an agent — the more accurate context the description contains, the better the agent will understand and execute the task. Use your judgment to gather context: if the user input contains URLs or references, fetch them and summarize the relevant parts. Spell out what needs to be done, what the background is, and any constraints or details that would help the executing agent avoid misunderstandings. Never echo the title here.\n")
|
||||
b.WriteString("- priority: one of `urgent`, `high`, `medium`, `low`, or omit. Map P0/P1 → urgent/high; \"asap\" → urgent. If unspecified, omit.\n")
|
||||
b.WriteString("- title: required. A short, imperative summary extracted from the user input (e.g. \"fix inbox loading\"). Strip filler words.\n")
|
||||
b.WriteString("- description: optional. Include only if the user supplied detail beyond the title; otherwise omit. Never echo the title here.\n")
|
||||
b.WriteString("- priority: one of `urgent`, `high`, `medium`, `low`, or omit. Map P0/P1 → urgent/high; \"asap\"/\"紧急\" → urgent; \"低优先级\" → low. If unspecified, omit.\n")
|
||||
b.WriteString("- assignee:\n")
|
||||
b.WriteString(" - When the user names someone (\"assign to X\" / \"@X\"), call `multica workspace members --output json` and find the matching member by display name (case-insensitive substring match is fine). On a clean match, pass `--assignee <name>`. On no match or ambiguous match, do NOT pass `--assignee` — instead append a final line to the description: `Unrecognized assignee: X`.\n")
|
||||
b.WriteString(" - When the user names someone (\"分给 X\" / \"assign to X\" / \"@X\"), call `multica workspace members --output json` and find the matching member by display name (case-insensitive substring match is fine). On a clean match, pass `--assignee <name>`. On no match or ambiguous match, do NOT pass `--assignee` — instead append a final line to the description: `未识别 assignee: X`.\n")
|
||||
agentName := ""
|
||||
if task.Agent != nil {
|
||||
agentName = task.Agent.Name
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// validNotificationTypes is the set of notification types that users can configure.
|
||||
var validNotificationTypes = map[string]bool{
|
||||
"issue_assigned": true,
|
||||
"unassigned": true,
|
||||
"assignee_changed": true,
|
||||
"status_changed": true,
|
||||
"priority_changed": true,
|
||||
"due_date_changed": true,
|
||||
"new_comment": true,
|
||||
"mentioned": true,
|
||||
"reaction_added": true,
|
||||
"task_completed": true,
|
||||
"task_failed": true,
|
||||
"agent_blocked": true,
|
||||
"agent_completed": true,
|
||||
"quick_create_done": true,
|
||||
"quick_create_failed": true,
|
||||
}
|
||||
|
||||
// DefaultDisabledTypes are notification types that are OFF by default (no row = disabled).
|
||||
// All other types default to ON (no row = enabled).
|
||||
// Exported so notification_listeners can reference the same defaults.
|
||||
var DefaultDisabledTypes = map[string]bool{
|
||||
"status_changed": true,
|
||||
}
|
||||
|
||||
type NotificationPreferenceResponse struct {
|
||||
NotificationType string `json:"notification_type"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (h *Handler) ListNotificationPreferences(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
workspaceID := ctxWorkspaceID(r.Context())
|
||||
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
prefs, err := h.Queries.ListNotificationPreferences(r.Context(), db.ListNotificationPreferencesParams{
|
||||
WorkspaceID: wsUUID,
|
||||
UserID: parseUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list notification preferences")
|
||||
return
|
||||
}
|
||||
|
||||
// Build a map of explicitly set preferences
|
||||
explicit := make(map[string]bool, len(prefs))
|
||||
for _, p := range prefs {
|
||||
explicit[p.NotificationType] = p.Enabled
|
||||
}
|
||||
|
||||
// Build full response with defaults for all valid types
|
||||
resp := make([]NotificationPreferenceResponse, 0, len(validNotificationTypes))
|
||||
for t := range validNotificationTypes {
|
||||
enabled := !DefaultDisabledTypes[t] // default: ON unless in defaultDisabledTypes
|
||||
if v, ok := explicit[t]; ok {
|
||||
enabled = v
|
||||
}
|
||||
resp = append(resp, NotificationPreferenceResponse{
|
||||
NotificationType: t,
|
||||
Enabled: enabled,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type updateNotificationPreferencesRequest struct {
|
||||
Preferences []NotificationPreferenceResponse `json:"preferences"`
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateNotificationPreferences(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
workspaceID := ctxWorkspaceID(r.Context())
|
||||
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req updateNotificationPreferencesRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Preferences) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "preferences cannot be empty")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate all notification types
|
||||
for _, p := range req.Preferences {
|
||||
if !validNotificationTypes[p.NotificationType] {
|
||||
writeError(w, http.StatusBadRequest, "invalid notification type: "+p.NotificationType)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert each preference
|
||||
for _, p := range req.Preferences {
|
||||
_, err := h.Queries.UpsertNotificationPreference(r.Context(), db.UpsertNotificationPreferenceParams{
|
||||
WorkspaceID: wsUUID,
|
||||
UserID: parseUUID(userID),
|
||||
NotificationType: p.NotificationType,
|
||||
Enabled: p.Enabled,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to update notification preference")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Return updated preferences
|
||||
h.ListNotificationPreferences(w, r)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
DROP TABLE IF EXISTS notification_preference;
|
||||
@@ -1,12 +0,0 @@
|
||||
CREATE TABLE notification_preference (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
notification_type TEXT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(workspace_id, user_id, notification_type)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_notification_preference_user ON notification_preference(workspace_id, user_id);
|
||||
@@ -53,11 +53,6 @@ type DeleteDaemonTokensByWorkspaceAndDaemonParams struct {
|
||||
DaemonID string `json:"daemon_id"`
|
||||
}
|
||||
|
||||
// Callers MUST also invalidate auth.DaemonTokenCache for each affected
|
||||
// token_hash so the deletion takes effect before the cache TTL expires.
|
||||
// Today this query has no caller; when a deregister / rotate flow lands,
|
||||
// change this to :many RETURNING token_hash and call
|
||||
// DaemonTokenCache.Invalidate(hash) for each row.
|
||||
func (q *Queries) DeleteDaemonTokensByWorkspaceAndDaemon(ctx context.Context, arg DeleteDaemonTokensByWorkspaceAndDaemonParams) error {
|
||||
_, err := q.db.Exec(ctx, deleteDaemonTokensByWorkspaceAndDaemon, arg.WorkspaceID, arg.DaemonID)
|
||||
return err
|
||||
|
||||
@@ -321,16 +321,6 @@ type Member struct {
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type NotificationPreference struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
NotificationType string `json:"notification_type"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type PersonalAccessToken struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: notification_preferences.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const deleteNotificationPreferences = `-- name: DeleteNotificationPreferences :exec
|
||||
DELETE FROM notification_preference
|
||||
WHERE workspace_id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
type DeleteNotificationPreferencesParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteNotificationPreferences(ctx context.Context, arg DeleteNotificationPreferencesParams) error {
|
||||
_, err := q.db.Exec(ctx, deleteNotificationPreferences, arg.WorkspaceID, arg.UserID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getDisabledNotificationTypes = `-- name: GetDisabledNotificationTypes :many
|
||||
SELECT notification_type FROM notification_preference
|
||||
WHERE workspace_id = $1 AND user_id = $2 AND enabled = false
|
||||
`
|
||||
|
||||
type GetDisabledNotificationTypesParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetDisabledNotificationTypes(ctx context.Context, arg GetDisabledNotificationTypesParams) ([]string, error) {
|
||||
rows, err := q.db.Query(ctx, getDisabledNotificationTypes, arg.WorkspaceID, arg.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []string{}
|
||||
for rows.Next() {
|
||||
var notification_type string
|
||||
if err := rows.Scan(¬ification_type); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, notification_type)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listNotificationPreferences = `-- name: ListNotificationPreferences :many
|
||||
SELECT id, workspace_id, user_id, notification_type, enabled, created_at, updated_at FROM notification_preference
|
||||
WHERE workspace_id = $1 AND user_id = $2
|
||||
ORDER BY notification_type
|
||||
`
|
||||
|
||||
type ListNotificationPreferencesParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListNotificationPreferences(ctx context.Context, arg ListNotificationPreferencesParams) ([]NotificationPreference, error) {
|
||||
rows, err := q.db.Query(ctx, listNotificationPreferences, arg.WorkspaceID, arg.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []NotificationPreference{}
|
||||
for rows.Next() {
|
||||
var i NotificationPreference
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.UserID,
|
||||
&i.NotificationType,
|
||||
&i.Enabled,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listNotificationPreferencesByType = `-- name: ListNotificationPreferencesByType :many
|
||||
SELECT user_id, enabled FROM notification_preference
|
||||
WHERE workspace_id = $1 AND notification_type = $2
|
||||
`
|
||||
|
||||
type ListNotificationPreferencesByTypeParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
NotificationType string `json:"notification_type"`
|
||||
}
|
||||
|
||||
type ListNotificationPreferencesByTypeRow struct {
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListNotificationPreferencesByType(ctx context.Context, arg ListNotificationPreferencesByTypeParams) ([]ListNotificationPreferencesByTypeRow, error) {
|
||||
rows, err := q.db.Query(ctx, listNotificationPreferencesByType, arg.WorkspaceID, arg.NotificationType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListNotificationPreferencesByTypeRow{}
|
||||
for rows.Next() {
|
||||
var i ListNotificationPreferencesByTypeRow
|
||||
if err := rows.Scan(&i.UserID, &i.Enabled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const upsertNotificationPreference = `-- name: UpsertNotificationPreference :one
|
||||
INSERT INTO notification_preference (workspace_id, user_id, notification_type, enabled)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (workspace_id, user_id, notification_type)
|
||||
DO UPDATE SET enabled = $4, updated_at = now()
|
||||
RETURNING id, workspace_id, user_id, notification_type, enabled, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpsertNotificationPreferenceParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
NotificationType string `json:"notification_type"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertNotificationPreference(ctx context.Context, arg UpsertNotificationPreferenceParams) (NotificationPreference, error) {
|
||||
row := q.db.QueryRow(ctx, upsertNotificationPreference,
|
||||
arg.WorkspaceID,
|
||||
arg.UserID,
|
||||
arg.NotificationType,
|
||||
arg.Enabled,
|
||||
)
|
||||
var i NotificationPreference
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.UserID,
|
||||
&i.NotificationType,
|
||||
&i.Enabled,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
-- name: ListNotificationPreferences :many
|
||||
SELECT * FROM notification_preference
|
||||
WHERE workspace_id = $1 AND user_id = $2
|
||||
ORDER BY notification_type;
|
||||
|
||||
-- name: UpsertNotificationPreference :one
|
||||
INSERT INTO notification_preference (workspace_id, user_id, notification_type, enabled)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (workspace_id, user_id, notification_type)
|
||||
DO UPDATE SET enabled = $4, updated_at = now()
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetDisabledNotificationTypes :many
|
||||
SELECT notification_type FROM notification_preference
|
||||
WHERE workspace_id = $1 AND user_id = $2 AND enabled = false;
|
||||
|
||||
-- name: ListNotificationPreferencesByType :many
|
||||
SELECT user_id, enabled FROM notification_preference
|
||||
WHERE workspace_id = $1 AND notification_type = $2;
|
||||
|
||||
-- name: DeleteNotificationPreferences :exec
|
||||
DELETE FROM notification_preference
|
||||
WHERE workspace_id = $1 AND user_id = $2;
|
||||
Reference in New Issue
Block a user