Compare commits

..

1 Commits

Author SHA1 Message Date
Jiayuan
58a8655b43 fix(web): remember last selected workspace after re-login
Stop clearing multica_workspace_id from localStorage on logout so it
persists as a preference hint. On fresh login, pass the stored ID to
hydrateWorkspace so the user returns to their last workspace instead
of always landing on the first one.
2026-04-06 00:59:35 +08:00
29 changed files with 89 additions and 438 deletions

View File

@@ -153,7 +153,8 @@ function LoginPageContent() {
await verifyCode(email, value);
const wsList = await api.listWorkspaces();
await hydrateWorkspace(wsList);
const lastWsId = localStorage.getItem("multica_workspace_id");
await hydrateWorkspace(wsList, lastWsId);
router.push(searchParams.get("next") || "/issues");
} catch (err) {
setError(

View File

@@ -36,7 +36,6 @@ export const useAuthStore = create<AuthState>((set) => ({
api.setToken(null);
api.setWorkspaceId(null);
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
set({ user: null, isLoading: false });
}
},
@@ -56,7 +55,6 @@ export const useAuthStore = create<AuthState>((set) => ({
logout: () => {
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
api.setToken(null);
api.setWorkspaceId(null);
clearLoggedInCookie();

View File

@@ -148,8 +148,6 @@ function CommentRow({
};
const reactions = entry.reactions ?? [];
const contentText = entry.content ?? "";
const isLongContent = contentText.length > 500 || contentText.split("\n").length > 8;
return (
<div className={`py-3${isTemp ? " opacity-60" : ""}`}>
@@ -254,7 +252,7 @@ function CommentRow({
reactions={reactions}
currentUserId={currentUserId}
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
hideAddButton={!isLongContent}
hideAddButton
className="mt-1.5 pl-8"
/>
)}
@@ -332,8 +330,6 @@ function CommentCard({
const replyCount = allNestedReplies.length;
const contentPreview = (entry.content ?? "").replace(/\n/g, " ").slice(0, 80);
const reactions = entry.reactions ?? [];
const contentText = entry.content ?? "";
const isLongContent = contentText.length > 500 || contentText.split("\n").length > 8;
const isHighlighted = highlightedCommentId === entry.id;
@@ -462,7 +458,6 @@ function CommentCard({
reactions={reactions}
currentUserId={currentUserId}
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
hideAddButton={!isLongContent}
className="mt-1.5 pl-10"
/>
)}

View File

@@ -16,15 +16,10 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
const editorRef = useRef<ContentEditorRef>(null);
const [isEmpty, setIsEmpty] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
const { uploadWithToast } = useFileUpload();
const handleUpload = async (file: File) => {
const result = await uploadWithToast(file, { issueId });
if (result) {
setAttachmentIds((prev) => [...prev, result.id]);
}
return result;
return await uploadWithToast(file, { issueId });
};
const handleSubmit = async () => {
@@ -32,10 +27,9 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
if (!content || submitting) return;
setSubmitting(true);
try {
await onSubmit(content, attachmentIds.length > 0 ? attachmentIds : undefined);
await onSubmit(content);
editorRef.current?.clearContent();
setIsEmpty(true);
setAttachmentIds([]);
} finally {
setSubmitting(false);
}

View File

@@ -38,7 +38,6 @@ function ReplyInput({
const [isEmpty, setIsEmpty] = useState(true);
const [isExpanded, setIsExpanded] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
const { uploadWithToast } = useFileUpload();
useEffect(() => {
@@ -53,11 +52,7 @@ function ReplyInput({
}, []);
const handleUpload = async (file: File) => {
const result = await uploadWithToast(file, { issueId });
if (result) {
setAttachmentIds((prev) => [...prev, result.id]);
}
return result;
return await uploadWithToast(file, { issueId });
};
const handleSubmit = async () => {
@@ -65,10 +60,9 @@ function ReplyInput({
if (!content || submitting) return;
setSubmitting(true);
try {
await onSubmit(content, attachmentIds.length > 0 ? attachmentIds : undefined);
await onSubmit(content);
editorRef.current?.clearContent();
setIsEmpty(true);
setAttachmentIds([]);
} finally {
setSubmitting(false);
}

View File

@@ -93,16 +93,9 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
// Due date popover
const [dueDateOpen, setDueDateOpen] = useState(false);
// File upload — collect attachment IDs so we can link them after issue creation.
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
// File upload
const { uploadWithToast } = useFileUpload();
const handleUpload = async (file: File) => {
const result = await uploadWithToast(file);
if (result) {
setAttachmentIds((prev) => [...prev, result.id]);
}
return result;
};
const handleUpload = (file: File) => uploadWithToast(file);
const assigneeQuery = assigneeFilter.toLowerCase();
const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(assigneeQuery));
@@ -137,7 +130,6 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
assignee_type: assigneeType,
assignee_id: assigneeId,
due_date: dueDate || undefined,
attachment_ids: attachmentIds.length > 0 ? attachmentIds : undefined,
});
useIssueStore.getState().addIssue(issue);
clearDraft();

View File

@@ -233,7 +233,6 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
clearWorkspace: () => {
api.setWorkspaceId(null);
localStorage.removeItem("multica_workspace_id");
set({ workspace: null, workspaces: [], members: [], agents: [], skills: [] });
},
}));

View File

@@ -11,7 +11,6 @@ export interface CreateIssueRequest {
assignee_id?: string;
parent_issue_id?: string;
due_date?: string;
attachment_ids?: string[];
}
export interface UpdateIssueRequest {

View File

@@ -17,7 +17,7 @@ import (
var agentCmd = &cobra.Command{
Use: "agent",
Short: "Work with agents",
Short: "Manage agents",
}
var agentListCmd = &cobra.Command{
@@ -29,7 +29,7 @@ var agentListCmd = &cobra.Command{
var agentGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Get agent details",
Args: exactArgs(1),
Args: cobra.ExactArgs(1),
RunE: runAgentGet,
}
@@ -42,28 +42,28 @@ var agentCreateCmd = &cobra.Command{
var agentUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update an agent",
Args: exactArgs(1),
Args: cobra.ExactArgs(1),
RunE: runAgentUpdate,
}
var agentArchiveCmd = &cobra.Command{
Use: "archive <id>",
Short: "Archive an agent",
Args: exactArgs(1),
Args: cobra.ExactArgs(1),
RunE: runAgentArchive,
}
var agentRestoreCmd = &cobra.Command{
Use: "restore <id>",
Short: "Restore an archived agent",
Args: exactArgs(1),
Args: cobra.ExactArgs(1),
RunE: runAgentRestore,
}
var agentTasksCmd = &cobra.Command{
Use: "tasks <id>",
Short: "List tasks for an agent",
Args: exactArgs(1),
Args: cobra.ExactArgs(1),
RunE: runAgentTasks,
}
@@ -77,14 +77,14 @@ var agentSkillsCmd = &cobra.Command{
var agentSkillsListCmd = &cobra.Command{
Use: "list <agent-id>",
Short: "List skills assigned to an agent",
Args: exactArgs(1),
Args: cobra.ExactArgs(1),
RunE: runAgentSkillsList,
}
var agentSkillsSetCmd = &cobra.Command{
Use: "set <agent-id>",
Short: "Set skills for an agent (replaces all current assignments)",
Args: exactArgs(1),
Args: cobra.ExactArgs(1),
RunE: runAgentSkillsSet,
}

View File

@@ -14,19 +14,14 @@ import (
var attachmentCmd = &cobra.Command{
Use: "attachment",
Short: "Work with attachments",
Short: "Manage attachments",
}
var attachmentDownloadCmd = &cobra.Command{
Use: "download <attachment-id>",
Short: "Download an attachment to a local file",
Long: "Download an attachment by its ID to a local file.",
Example: ` # Download an image attachment to the current directory
$ multica attachment download abc123
# Download to a specific directory
$ multica attachment download abc123 -o /tmp/images`,
Args: exactArgs(1),
Long: "Fetches the attachment metadata from the API, then downloads the file using its signed URL. Prints the local file path on success.",
Args: cobra.ExactArgs(1),
RunE: runAttachmentDownload,
}

View File

@@ -22,7 +22,7 @@ import (
var authCmd = &cobra.Command{
Use: "auth",
Short: "Authenticate multica with Multica",
Short: "Manage authentication",
}
var authLoginCmd = &cobra.Command{

View File

@@ -11,7 +11,7 @@ import (
var configCmd = &cobra.Command{
Use: "config",
Short: "Manage configuration for multica",
Short: "Show CLI configuration",
RunE: runConfigShow,
}
@@ -25,7 +25,7 @@ var configSetCmd = &cobra.Command{
Use: "set <key> <value>",
Short: "Set a CLI configuration value",
Long: "Supported keys: server_url, app_url, workspace_id",
Args: exactArgs(2),
Args: cobra.ExactArgs(2),
RunE: runConfigSet,
}

View File

@@ -23,7 +23,7 @@ import (
var daemonCmd = &cobra.Command{
Use: "daemon",
Short: "Control the local agent runtime daemon",
Short: "Manage the local agent runtime daemon",
}
var daemonStartCmd = &cobra.Command{

View File

@@ -16,7 +16,7 @@ import (
var issueCmd = &cobra.Command{
Use: "issue",
Short: "Work with issues",
Short: "Manage issues",
}
var issueListCmd = &cobra.Command{
@@ -28,7 +28,7 @@ var issueListCmd = &cobra.Command{
var issueGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Get issue details",
Args: exactArgs(1),
Args: cobra.ExactArgs(1),
RunE: runIssueGet,
}
@@ -41,21 +41,21 @@ var issueCreateCmd = &cobra.Command{
var issueUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update an issue",
Args: exactArgs(1),
Args: cobra.ExactArgs(1),
RunE: runIssueUpdate,
}
var issueAssignCmd = &cobra.Command{
Use: "assign <id>",
Short: "Assign an issue to a member or agent",
Args: exactArgs(1),
Args: cobra.ExactArgs(1),
RunE: runIssueAssign,
}
var issueStatusCmd = &cobra.Command{
Use: "status <id> <status>",
Short: "Change issue status",
Args: exactArgs(2),
Args: cobra.ExactArgs(2),
RunE: runIssueStatus,
}
@@ -63,27 +63,27 @@ var issueStatusCmd = &cobra.Command{
var issueCommentCmd = &cobra.Command{
Use: "comment",
Short: "Work with issue comments",
Short: "Manage issue comments",
}
var issueCommentListCmd = &cobra.Command{
Use: "list <issue-id>",
Short: "List comments on an issue",
Args: exactArgs(1),
Args: cobra.ExactArgs(1),
RunE: runIssueCommentList,
}
var issueCommentAddCmd = &cobra.Command{
Use: "add <issue-id>",
Short: "Add a comment to an issue",
Args: exactArgs(1),
Args: cobra.ExactArgs(1),
RunE: runIssueCommentAdd,
}
var issueCommentDeleteCmd = &cobra.Command{
Use: "delete <comment-id>",
Short: "Delete a comment",
Args: exactArgs(1),
Args: cobra.ExactArgs(1),
RunE: runIssueCommentDelete,
}
@@ -92,14 +92,14 @@ var issueCommentDeleteCmd = &cobra.Command{
var issueRunsCmd = &cobra.Command{
Use: "runs <issue-id>",
Short: "List execution history for an issue",
Args: exactArgs(1),
Args: cobra.ExactArgs(1),
RunE: runIssueRuns,
}
var issueRunMessagesCmd = &cobra.Command{
Use: "run-messages <task-id>",
Short: "List messages for an execution",
Args: exactArgs(1),
Args: cobra.ExactArgs(1),
RunE: runIssueRunMessages,
}

View File

@@ -14,14 +14,14 @@ import (
var repoCmd = &cobra.Command{
Use: "repo",
Short: "Work with repositories",
Short: "Manage repositories",
}
var repoCheckoutCmd = &cobra.Command{
Use: "checkout <url>",
Short: "Check out a repository into the working directory",
Long: "Creates a git worktree from the daemon's bare clone cache. Used by agents to check out repos on demand.",
Args: exactArgs(1),
Args: cobra.ExactArgs(1),
RunE: runRepoCheckout,
}

View File

@@ -13,7 +13,7 @@ import (
var runtimeCmd = &cobra.Command{
Use: "runtime",
Short: "Work with agent runtimes",
Short: "Manage agent runtimes",
}
var runtimeListCmd = &cobra.Command{
@@ -25,28 +25,28 @@ var runtimeListCmd = &cobra.Command{
var runtimeUsageCmd = &cobra.Command{
Use: "usage <runtime-id>",
Short: "Get token usage for a runtime",
Args: exactArgs(1),
Args: cobra.ExactArgs(1),
RunE: runRuntimeUsage,
}
var runtimeActivityCmd = &cobra.Command{
Use: "activity <runtime-id>",
Short: "Get hourly task activity for a runtime",
Args: exactArgs(1),
Args: cobra.ExactArgs(1),
RunE: runRuntimeActivity,
}
var runtimePingCmd = &cobra.Command{
Use: "ping <runtime-id>",
Short: "Ping a runtime to check connectivity",
Args: exactArgs(1),
Args: cobra.ExactArgs(1),
RunE: runRuntimePing,
}
var runtimeUpdateCmd = &cobra.Command{
Use: "update <runtime-id>",
Short: "Initiate a CLI update on a runtime",
Args: exactArgs(1),
Args: cobra.ExactArgs(1),
RunE: runRuntimeUpdate,
}

View File

@@ -16,7 +16,7 @@ import (
var skillCmd = &cobra.Command{
Use: "skill",
Short: "Work with skills",
Short: "Manage skills",
}
var skillListCmd = &cobra.Command{
@@ -28,7 +28,7 @@ var skillListCmd = &cobra.Command{
var skillGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Get skill details (includes files)",
Args: exactArgs(1),
Args: cobra.ExactArgs(1),
RunE: runSkillGet,
}
@@ -41,14 +41,14 @@ var skillCreateCmd = &cobra.Command{
var skillUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a skill",
Args: exactArgs(1),
Args: cobra.ExactArgs(1),
RunE: runSkillUpdate,
}
var skillDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a skill",
Args: exactArgs(1),
Args: cobra.ExactArgs(1),
RunE: runSkillDelete,
}
@@ -62,27 +62,27 @@ var skillImportCmd = &cobra.Command{
var skillFilesCmd = &cobra.Command{
Use: "files",
Short: "Work with skill files",
Short: "Manage skill files",
}
var skillFilesListCmd = &cobra.Command{
Use: "list <skill-id>",
Short: "List files for a skill",
Args: exactArgs(1),
Args: cobra.ExactArgs(1),
RunE: runSkillFilesList,
}
var skillFilesUpsertCmd = &cobra.Command{
Use: "upsert <skill-id>",
Short: "Create or update a skill file",
Args: exactArgs(1),
Args: cobra.ExactArgs(1),
RunE: runSkillFilesUpsert,
}
var skillFilesDeleteCmd = &cobra.Command{
Use: "delete <skill-id> <file-id>",
Short: "Delete a skill file",
Args: exactArgs(2),
Args: cobra.ExactArgs(2),
RunE: runSkillFilesDelete,
}

View File

@@ -15,7 +15,7 @@ import (
var workspaceCmd = &cobra.Command{
Use: "workspace",
Short: "Work with workspaces",
Short: "Manage workspaces",
}
var workspaceListCmd = &cobra.Command{
@@ -41,14 +41,14 @@ var workspaceMembersCmd = &cobra.Command{
var workspaceWatchCmd = &cobra.Command{
Use: "watch <workspace-id>",
Short: "Add a workspace to the daemon watch list",
Args: exactArgs(1),
Args: cobra.ExactArgs(1),
RunE: runWatch,
}
var workspaceUnwatchCmd = &cobra.Command{
Use: "unwatch <workspace-id>",
Short: "Remove a workspace from the daemon watch list",
Args: exactArgs(1),
Args: cobra.ExactArgs(1),
RunE: runUnwatch,
}

View File

@@ -1,173 +0,0 @@
package main
import (
"fmt"
"strings"
"text/template"
"github.com/spf13/cobra"
)
// Command group IDs used across the CLI.
const (
groupCore = "core"
groupRuntime = "runtime"
groupAdditional = "additional"
)
// errSilent is returned when the error message has already been printed.
var errSilent = fmt.Errorf("")
// exactArgs returns a cobra.PositionalArgs that validates the arg count
// and prints help on failure, so users see usage context with the error.
func exactArgs(n int) cobra.PositionalArgs {
return func(cmd *cobra.Command, args []string) error {
if len(args) != n {
if n == 1 {
fmt.Fprintf(cmd.ErrOrStderr(), "Error: accepts 1 arg, received %d\n\n", len(args))
} else {
fmt.Fprintf(cmd.ErrOrStderr(), "Error: accepts %d args, received %d\n\n", n, len(args))
}
cmd.Help()
return errSilent
}
return nil
}
}
// initHelp configures the root command to use gh-style help output.
func initHelp(root *cobra.Command) {
root.SetHelpTemplate(rootHelpTemplate)
root.SetUsageTemplate(rootHelpTemplate)
root.CompletionOptions.HiddenDefaultCmd = true
root.AddGroup(
&cobra.Group{ID: groupCore, Title: "CORE COMMANDS"},
&cobra.Group{ID: groupRuntime, Title: "RUNTIME COMMANDS"},
&cobra.Group{ID: groupAdditional, Title: "ADDITIONAL COMMANDS"},
)
// Apply gh-style templates to all commands recursively.
applyTemplates(root)
}
func applyTemplates(cmd *cobra.Command) {
for _, c := range cmd.Commands() {
if c.HasSubCommands() {
c.SetHelpTemplate(subHelpTemplate)
c.SetUsageTemplate(subHelpTemplate)
} else {
c.SetHelpTemplate(leafHelpTemplate)
c.SetUsageTemplate(leafHelpTemplate)
}
applyTemplates(c)
}
}
// formatCommandList formats a list of commands in "name: description" style
// with automatic alignment, matching gh's output.
func formatCommandList(cmds []*cobra.Command) string {
if len(cmds) == 0 {
return ""
}
maxLen := 0
for _, c := range cmds {
if c.IsAvailableCommand() && len(c.Name()) > maxLen {
maxLen = len(c.Name())
}
}
var b strings.Builder
for _, c := range cmds {
if !c.IsAvailableCommand() {
continue
}
padding := strings.Repeat(" ", maxLen-len(c.Name()))
fmt.Fprintf(&b, " %s:%s %s\n", c.Name(), padding, c.Short)
}
return b.String()
}
// commandsInGroup returns commands that belong to a specific group.
func commandsInGroup(cmds []*cobra.Command, groupID string) []*cobra.Command {
var result []*cobra.Command
for _, c := range cmds {
if c.GroupID == groupID && c.IsAvailableCommand() {
result = append(result, c)
}
}
return result
}
func init() {
cobra.AddTemplateFuncs(template.FuncMap{
"formatCommandList": formatCommandList,
"commandsInGroup": commandsInGroup,
})
}
var rootHelpTemplate = `Work seamlessly with Multica from the command line.
USAGE
multica <command> <subcommand> [flags]
{{range .Groups}}
{{.Title}}
{{formatCommandList (commandsInGroup $.Commands .ID)}}
{{- end}}
FLAGS
{{.LocalFlags.FlagUsages}}
EXAMPLES
$ multica login
$ multica issue list --output json
$ multica daemon start
$ multica agent list --output json
ENVIRONMENT VARIABLES
MULTICA_SERVER_URL Override the default server URL
MULTICA_WORKSPACE_ID Set the active workspace
LEARN MORE
Use ` + "`multica <command> <subcommand> --help`" + ` for more information about a command.
`
var subHelpTemplate = `{{.Short}}
USAGE
{{.CommandPath}} <command> [flags]
COMMANDS
{{formatCommandList .Commands}}
INHERITED FLAGS
--help Show help for command
{{- if .Example}}
EXAMPLES
{{.Example}}
{{- end}}
LEARN MORE
Use ` + "`{{.CommandPath}} <command> --help`" + ` for more information about a command.
`
var leafHelpTemplate = `{{if .Long}}{{.Long}}{{else}}{{.Short}}{{end}}
USAGE
{{.UseLine}}
{{- if .HasLocalFlags}}
FLAGS
{{.LocalFlags.FlagUsages}}
{{- end}}
INHERITED FLAGS
--help Show help for command
{{- if .Example}}
EXAMPLES
{{.Example}}
{{- end}}
LEARN MORE
Use ` + "`multica <command> <subcommand> --help`" + ` for more information about a command.
`

View File

@@ -15,7 +15,7 @@ var (
var rootCmd = &cobra.Command{
Use: "multica",
Short: "Multica CLI — local agent runtime and management tool",
Long: "Work seamlessly with Multica from the command line.",
Long: "multica manages local agent runtimes and provides control commands for the Multica platform.",
SilenceUsage: true,
SilenceErrors: true,
}
@@ -25,47 +25,24 @@ func init() {
rootCmd.PersistentFlags().String("workspace-id", "", "Workspace ID (env: MULTICA_WORKSPACE_ID)")
rootCmd.PersistentFlags().String("profile", "", "Configuration profile name (e.g. dev) — isolates config, daemon state, and workspaces")
// Core commands
issueCmd.GroupID = groupCore
agentCmd.GroupID = groupCore
workspaceCmd.GroupID = groupCore
repoCmd.GroupID = groupCore
skillCmd.GroupID = groupCore
// Runtime commands
daemonCmd.GroupID = groupRuntime
runtimeCmd.GroupID = groupRuntime
// Additional commands
authCmd.GroupID = groupAdditional
loginCmd.GroupID = groupAdditional
attachmentCmd.GroupID = groupAdditional
configCmd.GroupID = groupAdditional
updateCmd.GroupID = groupAdditional
versionCmd.GroupID = groupAdditional
rootCmd.AddCommand(issueCmd)
rootCmd.AddCommand(loginCmd)
rootCmd.AddCommand(authCmd)
rootCmd.AddCommand(daemonCmd)
rootCmd.AddCommand(agentCmd)
rootCmd.AddCommand(workspaceCmd)
rootCmd.AddCommand(repoCmd)
rootCmd.AddCommand(skillCmd)
rootCmd.AddCommand(daemonCmd)
rootCmd.AddCommand(runtimeCmd)
rootCmd.AddCommand(authCmd)
rootCmd.AddCommand(loginCmd)
rootCmd.AddCommand(attachmentCmd)
rootCmd.AddCommand(configCmd)
rootCmd.AddCommand(updateCmd)
rootCmd.AddCommand(issueCmd)
rootCmd.AddCommand(attachmentCmd)
rootCmd.AddCommand(repoCmd)
rootCmd.AddCommand(versionCmd)
initHelp(rootCmd)
rootCmd.AddCommand(updateCmd)
rootCmd.AddCommand(skillCmd)
rootCmd.AddCommand(runtimeCmd)
}
func main() {
if err := rootCmd.Execute(); err != nil {
if err != errSilent {
fmt.Fprintln(os.Stderr, "Error:", err)
}
fmt.Fprintln(os.Stderr, "Error:", err)
os.Exit(1)
}
}

View File

@@ -381,34 +381,6 @@ func TestCommentTriggerThreadInheritedMention(t *testing.T) {
t.Errorf("expected 1 pending task (no duplicate), got %d", n)
}
})
t.Run("reply mentioning only a member does not inherit agent mention", func(t *testing.T) {
clearTasks(t, issueID)
// Top-level comment @mentions the agent.
content := fmt.Sprintf("[@Agent](mention://agent/%s) can you help?", agentID)
threadID := postComment(t, issueID, content, nil)
clearTasks(t, issueID)
// Reply mentions only a member — should NOT inherit parent's agent mention.
reply := fmt.Sprintf("cc [@Someone](mention://member/%s)", testUserID)
postComment(t, issueID, reply, strPtr(threadID))
if n := countPendingTasks(t, issueID); n != 0 {
t.Errorf("expected 0 pending tasks (member-only reply should not inherit agent mention), got %d", n)
}
})
t.Run("reply mentioning agent and member still inherits", func(t *testing.T) {
clearTasks(t, issueID)
// Top-level comment @mentions the agent.
content := fmt.Sprintf("[@Agent](mention://agent/%s) review this", agentID)
threadID := postComment(t, issueID, content, nil)
clearTasks(t, issueID)
// Reply mentions both agent and member — should still trigger.
reply := fmt.Sprintf("[@Agent](mention://agent/%s) and cc [@Someone](mention://member/%s)", agentID, testUserID)
postComment(t, issueID, reply, strPtr(threadID))
if n := countPendingTasks(t, issueID); n != 1 {
t.Errorf("expected 1 pending task (reply mentions agent explicitly), got %d", n)
}
})
}
// TestCommentTriggerCoalescing verifies that rapid-fire comments don't create

View File

@@ -34,7 +34,6 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect

View File

@@ -48,8 +48,6 @@ github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=

View File

@@ -143,13 +143,6 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString("This downloads the file to the current directory and prints the local path. Use `-o <dir>` to save elsewhere.\n")
b.WriteString("After downloading, you can read the file directly (e.g. view an image, read a document).\n\n")
b.WriteString("## Important: Always Use the `multica` CLI\n\n")
b.WriteString("All interactions with Multica platform resources — including issues, comments, attachments, images, files, and any other platform data — **must** go through the `multica` CLI. ")
b.WriteString("Do NOT use `curl`, `wget`, or any other HTTP client to access Multica URLs or APIs directly. ")
b.WriteString("Multica resource URLs require authenticated access that only the `multica` CLI can provide.\n\n")
b.WriteString("If you need to perform an operation that is not covered by any existing `multica` command, ")
b.WriteString("do NOT attempt to work around it. Instead, post a comment mentioning the workspace owner to request the missing functionality.\n\n")
b.WriteString("## Output\n\n")
b.WriteString("Keep comments concise and natural — state the outcome, not the process.\n")
b.WriteString("Good: \"Fixed the login redirect. PR: https://...\"\n")

View File

@@ -354,9 +354,7 @@ func (h *Handler) isReplyToMemberThread(parent *db.Comment, content string, issu
// enqueues a task for each mentioned agent. When parentComment is non-nil
// (i.e. the comment is a reply), mentions from the parent (thread root) are
// also included so that agents mentioned in the top-level comment are
// re-triggered by subsequent replies in the same thread — unless the reply
// explicitly @mentions only non-agent entities (members, issues), which
// signals the user is talking to other people and not the agent.
// re-triggered by subsequent replies in the same thread.
// Skips self-mentions, agents that are already the issue's assignee (handled
// by on_comment), agents with on_mention trigger disabled, and private agents
// mentioned by non-owner members (only the agent owner or workspace
@@ -368,31 +366,17 @@ func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue
mentions := util.ParseMentions(comment.Content)
// When replying in a thread, also include mentions from the parent comment
// so that agents mentioned in the thread root are triggered by replies.
// However, skip inheritance when the reply explicitly @mentions only
// non-agent entities (members, issues) — the user is directing the reply
// at other people, not requesting work from agents in the parent thread.
if parentComment != nil {
hasAgentMention := false
hasNonAgentMention := false
parentMentions := util.ParseMentions(parentComment.Content)
seen := make(map[string]bool, len(mentions))
for _, m := range mentions {
if m.Type == "agent" {
hasAgentMention = true
} else {
hasNonAgentMention = true
}
seen[m.Type+":"+m.ID] = true
}
if hasAgentMention || !hasNonAgentMention {
parentMentions := util.ParseMentions(parentComment.Content)
seen := make(map[string]bool, len(mentions))
for _, m := range mentions {
for _, m := range parentMentions {
if !seen[m.Type+":"+m.ID] {
mentions = append(mentions, m)
seen[m.Type+":"+m.ID] = true
}
for _, m := range parentMentions {
if !seen[m.Type+":"+m.ID] {
mentions = append(mentions, m)
seen[m.Type+":"+m.ID] = true
}
}
}
}
for _, m := range mentions {

View File

@@ -2,6 +2,8 @@ package handler
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"log/slog"
@@ -10,7 +12,6 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
@@ -133,14 +134,13 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
return
}
// Generate a UUIDv7 to use as both the attachment ID and S3 key.
id, err := uuid.NewV7()
if err != nil {
slog.Error("failed to generate uuid", "error", err)
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
slog.Error("failed to generate file key", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
key := id.String() + path.Ext(header.Filename)
key := hex.EncodeToString(b) + path.Ext(header.Filename)
link, err := h.Storage.Upload(r.Context(), key, data, contentType, header.Filename)
if err != nil {
@@ -154,7 +154,6 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
uploaderType, uploaderID := h.resolveActor(r, userID, workspaceID)
params := db.CreateAttachmentParams{
ID: pgtype.UUID{Bytes: id, Valid: true},
WorkspaceID: parseUUID(workspaceID),
UploaderType: uploaderType,
UploaderID: parseUUID(uploaderID),
@@ -296,22 +295,6 @@ func (h *Handler) DeleteAttachment(w http.ResponseWriter, r *http.Request) {
// Attachment linking
// ---------------------------------------------------------------------------
// linkAttachmentsByIssueIDs links the given attachment IDs to an issue.
// Only updates attachments that have no issue_id yet.
func (h *Handler) linkAttachmentsByIssueIDs(ctx context.Context, issueID, workspaceID pgtype.UUID, ids []string) {
uuids := make([]pgtype.UUID, len(ids))
for i, id := range ids {
uuids[i] = parseUUID(id)
}
if err := h.Queries.LinkAttachmentsToIssue(ctx, db.LinkAttachmentsToIssueParams{
IssueID: issueID,
WorkspaceID: workspaceID,
Column3: uuids,
}); err != nil {
slog.Error("failed to link attachments to issue", "error", err)
}
}
// linkAttachmentsByIDs links the given attachment IDs to a comment.
// Only updates attachments that belong to the same issue and have no comment_id yet.
func (h *Handler) linkAttachmentsByIDs(ctx context.Context, commentID, issueID pgtype.UUID, ids []string) {

View File

@@ -170,15 +170,14 @@ func (h *Handler) GetIssue(w http.ResponseWriter, r *http.Request) {
}
type CreateIssueRequest struct {
Title string `json:"title"`
Description *string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
ParentIssueID *string `json:"parent_issue_id"`
DueDate *string `json:"due_date"`
AttachmentIDs []string `json:"attachment_ids,omitempty"`
Title string `json:"title"`
Description *string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
ParentIssueID *string `json:"parent_issue_id"`
DueDate *string `json:"due_date"`
}
func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
@@ -288,28 +287,8 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
return
}
// Link any pre-uploaded attachments to this issue.
if len(req.AttachmentIDs) > 0 {
h.linkAttachmentsByIssueIDs(r.Context(), issue.ID, issue.WorkspaceID, req.AttachmentIDs)
}
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
resp := issueToResponse(issue, prefix)
// Fetch linked attachments so they appear in the response.
if len(req.AttachmentIDs) > 0 {
attachments, err := h.Queries.ListAttachmentsByIssue(r.Context(), db.ListAttachmentsByIssueParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
})
if err == nil && len(attachments) > 0 {
resp.Attachments = make([]AttachmentResponse, len(attachments))
for i, a := range attachments {
resp.Attachments[i] = h.attachmentToResponse(a)
}
}
}
slog.Info("issue created", append(logger.RequestAttrs(r), "issue_id", uuidToString(issue.ID), "title", issue.Title, "status", issue.Status, "workspace_id", workspaceID)...)
h.publish(protocol.EventIssueCreated, workspaceID, creatorType, actualCreatorID, map[string]any{"issue": resp})

View File

@@ -12,13 +12,12 @@ import (
)
const createAttachment = `-- name: CreateAttachment :one
INSERT INTO attachment (id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes)
VALUES ($1, $2, $9, $10, $3, $4, $5, $6, $7, $8)
INSERT INTO attachment (workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes)
VALUES ($1, $8, $9, $2, $3, $4, $5, $6, $7)
RETURNING id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at
`
type CreateAttachmentParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
UploaderType string `json:"uploader_type"`
UploaderID pgtype.UUID `json:"uploader_id"`
@@ -32,7 +31,6 @@ type CreateAttachmentParams struct {
func (q *Queries) CreateAttachment(ctx context.Context, arg CreateAttachmentParams) (Attachment, error) {
row := q.db.QueryRow(ctx, createAttachment,
arg.ID,
arg.WorkspaceID,
arg.UploaderType,
arg.UploaderID,
@@ -122,25 +120,6 @@ func (q *Queries) LinkAttachmentsToComment(ctx context.Context, arg LinkAttachme
return err
}
const linkAttachmentsToIssue = `-- name: LinkAttachmentsToIssue :exec
UPDATE attachment
SET issue_id = $1
WHERE workspace_id = $2
AND issue_id IS NULL
AND id = ANY($3::uuid[])
`
type LinkAttachmentsToIssueParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Column3 []pgtype.UUID `json:"column_3"`
}
func (q *Queries) LinkAttachmentsToIssue(ctx context.Context, arg LinkAttachmentsToIssueParams) error {
_, err := q.db.Exec(ctx, linkAttachmentsToIssue, arg.IssueID, arg.WorkspaceID, arg.Column3)
return err
}
const listAttachmentURLsByCommentID = `-- name: ListAttachmentURLsByCommentID :many
SELECT url FROM attachment
WHERE comment_id = $1

View File

@@ -1,6 +1,6 @@
-- name: CreateAttachment :one
INSERT INTO attachment (id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes)
VALUES ($1, $2, sqlc.narg(issue_id), sqlc.narg(comment_id), $3, $4, $5, $6, $7, $8)
INSERT INTO attachment (workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes)
VALUES ($1, sqlc.narg(issue_id), sqlc.narg(comment_id), $2, $3, $4, $5, $6, $7)
RETURNING *;
-- name: ListAttachmentsByIssue :many
@@ -38,12 +38,5 @@ WHERE issue_id = $2
AND comment_id IS NULL
AND id = ANY($3::uuid[]);
-- name: LinkAttachmentsToIssue :exec
UPDATE attachment
SET issue_id = $1
WHERE workspace_id = $2
AND issue_id IS NULL
AND id = ANY($3::uuid[]);
-- name: DeleteAttachment :exec
DELETE FROM attachment WHERE id = $1 AND workspace_id = $2;