File size: 4,684 Bytes
63522a5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
import { Provider } from "#/types/settings";
import type { PluginSpec } from "#/api/conversation-service/agent-server-conversation-service.types";

const STORAGE_KEY = "openhands-agent-server-conversation-metadata";

export type WorkspaceMode = "local_repo" | "new_worktree";

export interface ConversationMetadata {
  selected_repository: string | null;
  selected_branch: string | null;
  git_provider: Provider | null;
  /**
   * The local workspace path the user explicitly attached at conversation
   * creation time. Distinct from `selected_repository` (which is set by
   * the repo picker on the home page). Used by the Files tab to decide
   * whether to default to diff view: if the user attached *anything*
   * (repo or local workspace), we lean diff-first because there's a real
   * git baseline to compare against.
   */
  selected_workspace?: string | null;
  /**
   * How the conversation should use `selected_workspace`.
   *
   * `local_repo` means the runtime should operate directly in the selected
   * folder, even when it is not a git checkout. `new_worktree` preserves the
   * historical agent-server behavior for conversations that should start in a
   * generated per-conversation worktree.
   */
  workspace_mode?: WorkspaceMode | null;
  /**
   * The LLM profile the conversation was created with (or last switched to).
   * Client-side only. Lets the chat-header switcher show the exact profile
   * name even when several profiles share the same underlying model — the
   * agent-server only round-trips the model string, so matching on it alone
   * is ambiguous (issue #1082).
   */
  active_profile?: string | null;
  /**
   * When `active_profile` was stamped (ISO string). Written by the live
   * WebSocket handler (the observation's server timestamp) and the user
   * `/model` mutation (client clock). The history seed compares the latest
   * SwitchLLM observation's timestamp against this so a reload can repair a
   * missed agent switch without rolling back a newer manual switch — manual
   * `/model` switches go through REST and leave no observation in history.
   * Absent on stamps written before this field existed; treated as older
   * than any observation so the seed can still repair them.
   */
  stamped_at?: string | null;
  /** Store plugin coordinates only; parameters may contain secrets. */
  plugins?: PluginSpec[] | null;
  /**
   * Local-only client-managed planning conversation id. Cloud backends expose
   * real sub-conversations through `sub_conversation_ids`; local agent-server
   * backends do not, so Canvas stores the relationship here.
   */
  local_planning_conversation_id?: string | null;
}

export const toPluginCoordinates = (plugin: PluginSpec): PluginSpec => ({
  source: plugin.source,
  ref: plugin.ref ?? null,
  repo_path: plugin.repo_path ?? null,
});

type StoredMetadata = Record<string, ConversationMetadata>;

const readAll = (): StoredMetadata => {
  if (typeof window === "undefined") return {};
  try {
    const raw = window.localStorage.getItem(STORAGE_KEY);
    if (!raw) return {};
    const parsed = JSON.parse(raw) as unknown;
    if (!parsed || typeof parsed !== "object") return {};
    return parsed as StoredMetadata;
  } catch {
    return {};
  }
};

const writeAll = (next: StoredMetadata): void => {
  if (typeof window === "undefined") return;
  if (Object.keys(next).length === 0) {
    window.localStorage.removeItem(STORAGE_KEY);
    return;
  }
  window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
};

export const getStoredConversationMetadata = (
  conversationId: string,
): ConversationMetadata | null => readAll()[conversationId] ?? null;

export const setStoredConversationMetadata = (
  conversationId: string,
  metadata: ConversationMetadata,
): void => {
  const all = readAll();
  all[conversationId] = metadata;
  writeAll(all);
};

/**
 * Merge `patch` into a conversation's stored metadata, preserving every
 * existing field — `setStoredConversationMetadata` does a full-object
 * replace, which would otherwise silently drop fields the caller doesn't
 * enumerate (e.g. `plugins`).
 */
export const mergeStoredConversationMetadata = (
  conversationId: string,
  patch: Partial<ConversationMetadata>,
): void => {
  setStoredConversationMetadata(conversationId, {
    selected_repository: null,
    selected_branch: null,
    git_provider: null,
    ...(getStoredConversationMetadata(conversationId) ?? {}),
    ...patch,
  });
};

export const removeStoredConversationMetadata = (
  conversationId: string,
): void => {
  const all = readAll();
  if (!(conversationId in all)) return;
  delete all[conversationId];
  writeAll(all);
};