mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-29 18:39:17 +02:00
Compare commits
7 Commits
v0.2.15
...
fix/api-co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
111d8aa5df | ||
|
|
98edc6b9ff | ||
|
|
88b892f1ca | ||
|
|
2cced51d64 | ||
|
|
6717db1fad | ||
|
|
2a248b8548 | ||
|
|
f84d216794 |
@@ -123,15 +123,21 @@ function AppContent() {
|
||||
// warning because `switchWorkspace` is a Zustand setState that the
|
||||
// TabBar is subscribed to. useLayoutEffect flushes both renders before
|
||||
// the user sees anything, so there's no visible flicker.
|
||||
//
|
||||
// Gate on `workspaceListFetched`: useQuery defaults `data` to `[]` before
|
||||
// the first fetch, so without this guard we'd run validation against an
|
||||
// empty slug set, wipe the persisted `activeWorkspaceSlug`, then fall
|
||||
// back to `workspaces[0]` once the real list arrives — losing the user's
|
||||
// last-opened workspace on every app start.
|
||||
useLayoutEffect(() => {
|
||||
if (!workspaces) return;
|
||||
if (!workspaceListFetched) return;
|
||||
const validSlugs = new Set(workspaces.map((w) => w.slug));
|
||||
const tabStore = useTabStore.getState();
|
||||
tabStore.validateWorkspaceSlugs(validSlugs);
|
||||
if (!tabStore.activeWorkspaceSlug && workspaces.length > 0) {
|
||||
tabStore.switchWorkspace(workspaces[0].slug);
|
||||
useTabStore.getState().validateWorkspaceSlugs(validSlugs);
|
||||
const { activeWorkspaceSlug, switchWorkspace } = useTabStore.getState();
|
||||
if (!activeWorkspaceSlug && workspaces.length > 0) {
|
||||
switchWorkspace(workspaces[0].slug);
|
||||
}
|
||||
}, [workspaces]);
|
||||
}, [workspaces, workspaceListFetched]);
|
||||
|
||||
// null = undecided (pre-login or list hasn't settled yet)
|
||||
// true = session started with zero workspaces; next transition to >=1 triggers restart
|
||||
|
||||
@@ -281,6 +281,31 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.15",
|
||||
date: "2026-04-22",
|
||||
title: "Local Skills, LaTeX, Focus Mode & Orphan-Task Recovery",
|
||||
changes: [],
|
||||
features: [
|
||||
"Import runtime local Skills into the workspace as first-class artifacts",
|
||||
"Orphan-task recovery — abandoned agent runs auto-retry, with manual rerun as fallback",
|
||||
"LaTeX rendering in issues, comments and chat",
|
||||
"Chat Focus mode — share the page you're on as conversation context",
|
||||
],
|
||||
improvements: [
|
||||
"Sub-issue `status_changed` events no longer spam parent-issue subscribers",
|
||||
"Multi-arch Docker release images built natively per-arch (no QEMU)",
|
||||
"Pin sidebar derives fields client-side for snappier reorders",
|
||||
"Expanded reserved-slug list so new slugs can't collide with product routes",
|
||||
],
|
||||
fixes: [
|
||||
"Gemini runtime model list now includes Gemini 3 and CLI aliases",
|
||||
"Chat focus button disabled on pages without an anchor",
|
||||
"Onboarding pin sync, welcome layout and runtime bootstrap state",
|
||||
"`install.ps1` OS architecture detection hardened for more Windows setups",
|
||||
"`/download` falls back to the previous release within a 1h freshness window",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.11",
|
||||
date: "2026-04-21",
|
||||
|
||||
@@ -281,6 +281,31 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.15",
|
||||
date: "2026-04-22",
|
||||
title: "本地 Skills、LaTeX、Focus 模式与孤儿任务自恢复",
|
||||
changes: [],
|
||||
features: [
|
||||
"支持将 Runtime 本地 Skills 导入工作区,成为一等工作区资产",
|
||||
"孤儿任务自动恢复——意外中断的 Agent 执行会自动重试,必要时可手动重跑",
|
||||
"Issue、评论与 Chat 支持 LaTeX 渲染",
|
||||
"Chat Focus 模式——将当前页面作为上下文分享给对话",
|
||||
],
|
||||
improvements: [
|
||||
"子 Issue 的 `status_changed` 事件不再向父 Issue 订阅者刷屏",
|
||||
"Docker 发布镜像改为按架构原生构建,免 QEMU",
|
||||
"侧边栏 Pin 字段在客户端派生,排序更跟手",
|
||||
"扩充保留 slug 列表,新工作区 slug 不会再和产品路由冲突",
|
||||
],
|
||||
fixes: [
|
||||
"Gemini Runtime 模型列表补上 Gemini 3 及若干 CLI 别名",
|
||||
"没有锚点的页面上 Chat focus 按钮改为禁用",
|
||||
"修复 Onboarding 中 Pin 同步、欢迎页布局与 Runtime bootstrap 状态",
|
||||
"`install.ps1` 的系统架构探测更稳健,覆盖更多 Windows 环境",
|
||||
"`/download` 在 1 小时新鲜度窗口内可回退到上一版本,避免撞上半发布状态",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.11",
|
||||
date: "2026-04-21",
|
||||
|
||||
@@ -27,6 +27,9 @@ export interface AgentTask {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
runtime_id: string;
|
||||
// Empty string ("") when the task has no linked issue — either chat- or
|
||||
// autopilot-spawned. Check chat_session_id / autopilot_run_id to tell
|
||||
// which source produced it.
|
||||
issue_id: string;
|
||||
status: "queued" | "dispatched" | "running" | "completed" | "failed" | "cancelled";
|
||||
priority: number;
|
||||
@@ -36,6 +39,10 @@ export interface AgentTask {
|
||||
result: unknown;
|
||||
error: string | null;
|
||||
created_at: string;
|
||||
/** Non-empty when the task was spawned from a chat session. */
|
||||
chat_session_id?: string;
|
||||
/** Non-empty when the task was spawned by an autopilot run. */
|
||||
autopilot_run_id?: string;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
|
||||
@@ -202,4 +202,56 @@ describe("TasksTab", () => {
|
||||
expect(label.closest("a")).toBeNull();
|
||||
expect(mockGetIssue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("labels chat-spawned tasks as 'Chat session'", async () => {
|
||||
renderTasksTab(
|
||||
[
|
||||
{
|
||||
id: "task-chat",
|
||||
agent_id: "agent-1",
|
||||
runtime_id: "runtime-1",
|
||||
issue_id: "",
|
||||
chat_session_id: "chat-42",
|
||||
status: "running",
|
||||
priority: 1,
|
||||
dispatched_at: "2026-04-16T00:30:00Z",
|
||||
started_at: "2026-04-16T00:31:00Z",
|
||||
completed_at: null,
|
||||
result: null,
|
||||
error: null,
|
||||
created_at: "2026-04-16T00:00:00Z",
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const label = await screen.findByText("Chat session");
|
||||
expect(label.closest("a")).toBeNull();
|
||||
});
|
||||
|
||||
it("labels autopilot-spawned tasks as 'Autopilot run'", async () => {
|
||||
renderTasksTab(
|
||||
[
|
||||
{
|
||||
id: "task-autopilot",
|
||||
agent_id: "agent-1",
|
||||
runtime_id: "runtime-1",
|
||||
issue_id: "",
|
||||
autopilot_run_id: "run-7",
|
||||
status: "completed",
|
||||
priority: 1,
|
||||
dispatched_at: null,
|
||||
started_at: null,
|
||||
completed_at: "2026-04-16T01:00:00Z",
|
||||
result: null,
|
||||
error: null,
|
||||
created_at: "2026-04-16T00:00:00Z",
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const label = await screen.findByText("Autopilot run");
|
||||
expect(label.closest("a")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,11 +106,18 @@ export function TasksTab({ agent }: { agent: Agent }) {
|
||||
{sortedTasks.map((task) => {
|
||||
const config = taskStatusConfig[task.status] ?? taskStatusConfig.queued!;
|
||||
const Icon = config.icon;
|
||||
// Tasks without a linked issue (autopilot run_only, chat-spawned,
|
||||
// etc.) carry issue_id = "" — skip the lookup and render them
|
||||
// as non-link rows.
|
||||
// Tasks without a linked issue carry issue_id = "" — skip the
|
||||
// detail lookup and render them as non-link rows. The source
|
||||
// label is picked from chat_session_id / autopilot_run_id,
|
||||
// which the server populates for chat- and autopilot-spawned
|
||||
// tasks respectively.
|
||||
const hasIssue = task.issue_id !== "";
|
||||
const issue = hasIssue ? issueMap.get(task.issue_id) : undefined;
|
||||
const sourcelessLabel = task.chat_session_id
|
||||
? "Chat session"
|
||||
: task.autopilot_run_id
|
||||
? "Autopilot run"
|
||||
: "Task without linked issue";
|
||||
const isActive = task.status === "running" || task.status === "dispatched";
|
||||
const isRunning = task.status === "running";
|
||||
const rowClassName = `flex items-center gap-3 rounded-lg border px-4 py-3 transition-shadow hover:shadow-sm ${
|
||||
@@ -136,7 +143,7 @@ export function TasksTab({ agent }: { agent: Agent }) {
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-sm truncate ${isActive ? "font-medium" : ""}`}>
|
||||
{issue?.title ?? (hasIssue ? `Issue ${task.issue_id.slice(0, 8)}...` : "Task without linked issue")}
|
||||
{issue?.title ?? (hasIssue ? `Issue ${task.issue_id.slice(0, 8)}...` : sourcelessLabel)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
|
||||
@@ -60,7 +60,7 @@ function IssueMention({
|
||||
};
|
||||
|
||||
return (
|
||||
<a href={issuePath} onClick={handleClick} className="inline-flex">
|
||||
<a href={issuePath} onClick={handleClick} className="issue-mention inline-flex">
|
||||
<IssueChip
|
||||
issueId={issueId}
|
||||
fallbackLabel={fallbackLabel}
|
||||
|
||||
@@ -18,7 +18,7 @@ interface IssueMentionCardProps {
|
||||
export function IssueMentionCard({ issueId, fallbackLabel }: IssueMentionCardProps) {
|
||||
const p = useWorkspacePaths();
|
||||
return (
|
||||
<AppLink href={p.issueDetail(issueId)} className="inline-flex">
|
||||
<AppLink href={p.issueDetail(issueId)} className="issue-mention not-prose inline-flex">
|
||||
<IssueChip
|
||||
issueId={issueId}
|
||||
fallbackLabel={fallbackLabel}
|
||||
|
||||
@@ -222,6 +222,23 @@ func TestHealth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRouteIsPublic(t *testing.T) {
|
||||
resp, err := http.Get(testServer.URL + "/api/config")
|
||||
if err != nil {
|
||||
t.Fatalf("config request failed: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
CdnDomain string `json:"cdn_domain"`
|
||||
}
|
||||
readJSON(t, resp, &result)
|
||||
}
|
||||
|
||||
// ---- Auth ----
|
||||
|
||||
func TestSendCodeAndVerify(t *testing.T) {
|
||||
|
||||
@@ -134,6 +134,9 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus, analytics
|
||||
r.Post("/auth/google", h.GoogleLogin)
|
||||
r.Post("/auth/logout", h.Logout)
|
||||
|
||||
// Public API
|
||||
r.Get("/api/config", h.GetConfig)
|
||||
|
||||
// Daemon API routes (require daemon token or valid user token)
|
||||
r.Route("/api/daemon", func(r chi.Router) {
|
||||
r.Use(middleware.DaemonAuth(queries))
|
||||
@@ -172,7 +175,6 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus, analytics
|
||||
r.Use(middleware.RefreshCloudFrontCookies(cfSigner))
|
||||
|
||||
// --- User-scoped routes (no workspace context required) ---
|
||||
r.Get("/api/config", h.GetConfig)
|
||||
r.Get("/api/me", h.GetMe)
|
||||
r.Patch("/api/me", h.UpdateMe)
|
||||
r.Patch("/api/me/onboarding", h.PatchOnboarding)
|
||||
|
||||
@@ -139,6 +139,7 @@ type AgentTaskResponse struct {
|
||||
TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment
|
||||
ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks
|
||||
ChatMessage string `json:"chat_message,omitempty"` // user message for chat tasks
|
||||
AutopilotRunID string `json:"autopilot_run_id,omitempty"` // non-empty for autopilot-spawned tasks
|
||||
}
|
||||
|
||||
// TaskAgentData holds agent info included in claim responses so the daemon
|
||||
@@ -181,6 +182,11 @@ func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse {
|
||||
ParentTaskID: uuidToPtr(t.ParentTaskID),
|
||||
CreatedAt: timestampToString(t.CreatedAt),
|
||||
TriggerCommentID: uuidToPtr(t.TriggerCommentID),
|
||||
// Surface task source so the UI can distinguish issue-linked tasks
|
||||
// from chat-spawned or autopilot-spawned ones; all three may arrive
|
||||
// with issue_id = "" once a task has no linked issue.
|
||||
ChatSessionID: uuidToString(t.ChatSessionID),
|
||||
AutopilotRunID: uuidToString(t.AutopilotRunID),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ type AppConfig struct {
|
||||
PosthogHost string `json:"posthog_host"`
|
||||
}
|
||||
|
||||
// GetConfig is mounted on the public (unauthenticated) route group because
|
||||
// the web app calls it before login to decide whether to render the Google
|
||||
// sign-in button and signup UI. Only add fields here that are safe to expose
|
||||
// to anonymous callers — never user- or tenant-scoped data.
|
||||
func (h *Handler) GetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
config := AppConfig{
|
||||
AllowSignup: os.Getenv("ALLOW_SIGNUP") != "false",
|
||||
|
||||
@@ -655,7 +655,7 @@ func discoverOpenclawAgents(ctx context.Context, executablePath string) ([]Model
|
||||
if _, err := exec.LookPath(executablePath); err != nil {
|
||||
return []Model{}, nil
|
||||
}
|
||||
runCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
runCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Try JSON modes first. Different openclaw builds expose the
|
||||
|
||||
Reference in New Issue
Block a user