@hienlh/ppm 0.13.12 → 0.13.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/.opencode/.env.example +98 -0
  2. package/.opencode/skills/ads-management/scripts/.env.example +13 -0
  3. package/.opencode/skills/ai-multimodal/.env.example +230 -0
  4. package/.opencode/skills/cip-design/.env.example +6 -0
  5. package/.opencode/skills/devops/.env.example +76 -0
  6. package/.opencode/skills/docs-seeker/.env.example +15 -0
  7. package/.opencode/skills/elevenlabs/.env.example +3 -0
  8. package/.opencode/skills/marketing-dashboard/.env.example +15 -0
  9. package/.opencode/skills/marketing-dashboard/app/.env.example +2 -0
  10. package/.opencode/skills/marketing-dashboard/server/.env.example +2 -0
  11. package/.opencode/skills/mcp-management/scripts/dist/analyze-tools.js +70 -0
  12. package/.opencode/skills/mcp-management/scripts/dist/cli.js +160 -0
  13. package/.opencode/skills/mcp-management/scripts/dist/mcp-client.js +183 -0
  14. package/.opencode/skills/payment-integration/scripts/.env.example +20 -0
  15. package/.opencode/skills/sequential-thinking/.env.example +8 -0
  16. package/CHANGELOG.md +5 -0
  17. package/assets/skills/ppm/SKILL.md +1 -1
  18. package/assets/skills/ppm/references/cli-reference.md +30 -4
  19. package/assets/skills/ppm/references/http-api.md +1 -1
  20. package/dist/web/assets/{ai-settings-section-ysK_Eixc.js → ai-settings-section-DR5BueEL.js} +1 -1
  21. package/dist/web/assets/{api-settings-D0_eiIYv.js → api-settings-DowGyuVy.js} +1 -1
  22. package/dist/web/assets/architecture-PBZL5I3N-7JKY4P1L.js +1 -0
  23. package/dist/web/assets/{audio-preview-BjoIjXlf.js → audio-preview-YOG6Biao.js} +1 -1
  24. package/dist/web/assets/chat-tab-DbdDJuLu.js +12 -0
  25. package/dist/web/assets/code-editor-C4nuAsy6.js +8 -0
  26. package/dist/web/assets/{conflict-editor-WxMZDucw.js → conflict-editor-DnGfriL5.js} +1 -1
  27. package/dist/web/assets/{csv-preview-7TsYBQI6.js → csv-preview-Bo-N3GHl.js} +1 -1
  28. package/dist/web/assets/{data-grid-overlay-editor-BjjuE4-G.js → data-grid-overlay-editor-DqcDQ9st.js} +1 -1
  29. package/dist/web/assets/{database-viewer-BRW8CMzC.js → database-viewer-AodppoTs.js} +1 -1
  30. package/dist/web/assets/diff-viewer-DykLUwna.js +4 -0
  31. package/dist/web/assets/{esm-zjerHxpO.js → esm-Dvc8oJly.js} +1 -1
  32. package/dist/web/assets/{extension-webview-DgfgR787.js → extension-webview-Bck7QuaB.js} +1 -1
  33. package/dist/web/assets/{file-store-BrbCNyLm.js → file-store-4BpOJthN.js} +1 -1
  34. package/dist/web/assets/gitGraph-HDMCJU4V-Daf9rhiF.js +1 -0
  35. package/dist/web/assets/{glide-data-grid-DIvkBUKj.js → glide-data-grid-BVt0mwcA.js} +7 -7
  36. package/dist/web/assets/{image-preview-BlBVP277.js → image-preview-DaSmrIvY.js} +1 -1
  37. package/dist/web/assets/index-CSK33ACc.css +2 -0
  38. package/dist/web/assets/index-gZKF1YKy.js +27 -0
  39. package/dist/web/assets/info-3K5VOQVL-gn0pjNiT.js +1 -0
  40. package/dist/web/assets/{input-ozrR2DAV.js → input-4ll___Gh.js} +1 -1
  41. package/dist/web/assets/keybindings-store-DBKLTPrk.js +1 -0
  42. package/dist/web/assets/{markdown-renderer-CJMJ5Qq0.js → markdown-renderer-B1me_hz2.js} +3 -3
  43. package/dist/web/assets/{number-overlay-editor-BoRxunFN.js → number-overlay-editor-XTjjEXtk.js} +1 -1
  44. package/dist/web/assets/packet-RMMSAZCW-Csaeizjc.js +1 -0
  45. package/dist/web/assets/{pdf-preview-D0JDPYYs.js → pdf-preview-Dci7TIL1.js} +1 -1
  46. package/dist/web/assets/pie-UPGHQEXC-DatkjxTH.js +1 -0
  47. package/dist/web/assets/port-forwarding-tab-BeM40G-J.js +1 -0
  48. package/dist/web/assets/{postgres-viewer-DkVKzTKJ.js → postgres-viewer-CGVBOwA9.js} +3 -3
  49. package/dist/web/assets/radar-KQ55EAFF-BnGB20hR.js +1 -0
  50. package/dist/web/assets/{scroll-area-7H-Q_k8c.js → scroll-area-iv39O3VN.js} +1 -1
  51. package/dist/web/assets/search-tM8K5zWU.js +1 -0
  52. package/dist/web/assets/{settings-store-Dvk8Lvwm.js → settings-store-D2MtC9tm.js} +2 -2
  53. package/dist/web/assets/settings-tab-CYS8VfNl.js +1 -0
  54. package/dist/web/assets/{sql-query-editor-BVn40O0T.js → sql-query-editor-DstPySPF.js} +1 -1
  55. package/dist/web/assets/sqlite-viewer-SUGEk_G1.js +1 -0
  56. package/dist/web/assets/{tab-store-0rGchMXr.js → tab-store-Dow2Ztto.js} +1 -1
  57. package/dist/web/assets/terminal-tab-CJvjF79J.js +1 -0
  58. package/dist/web/assets/treemap-KZPCXAKY-CgEYv38e.js +1 -0
  59. package/dist/web/assets/{use-blob-url-e9uTXjv5.js → use-blob-url-DGY5qKiT.js} +1 -1
  60. package/dist/web/assets/{use-monaco-theme-SyJzNaNN.js → use-monaco-theme-CugUkORI.js} +1 -1
  61. package/dist/web/assets/{vendor-mermaid-DsfY6y4f.js → vendor-mermaid-CPtQ2zua.js} +3 -3
  62. package/dist/web/assets/{video-preview-BEuZs1dG.js → video-preview-gJSKmPQr.js} +1 -1
  63. package/dist/web/index.html +16 -15
  64. package/dist/web/sw.js +1 -1
  65. package/docs/system-architecture.md +1 -0
  66. package/package.json +1 -1
  67. package/src/cli/commands/cloud.ts +53 -0
  68. package/src/cli/commands/db-cmd.ts +56 -0
  69. package/src/index.ts +0 -0
  70. package/src/providers/claude-agent-sdk.ts +12 -15
  71. package/src/services/cloud.service.ts +55 -0
  72. package/src/services/sqlite.service.ts +9 -0
  73. package/src/web/app.tsx +7 -0
  74. package/src/web/components/chat/chat-welcome.tsx +7 -140
  75. package/src/web/components/chat/session-list-panel.tsx +188 -0
  76. package/src/web/components/editor/diff-viewer.tsx +25 -26
  77. package/src/web/components/layout/editor-panel.tsx +10 -158
  78. package/bun.lock +0 -2135
  79. package/bunfig.toml +0 -2
  80. package/dist/web/assets/architecture-PBZL5I3N-DVlAZGlv.js +0 -1
  81. package/dist/web/assets/chat-tab-Cq8xYO7K.js +0 -12
  82. package/dist/web/assets/code-editor-DZ1e_sz0.js +0 -8
  83. package/dist/web/assets/diff-viewer-BOTb0dkG.js +0 -4
  84. package/dist/web/assets/gitGraph-HDMCJU4V-b3n-Tgk6.js +0 -1
  85. package/dist/web/assets/index-COOnLKGB.css +0 -2
  86. package/dist/web/assets/index-zP-OjEml.js +0 -27
  87. package/dist/web/assets/info-3K5VOQVL-fJy9dGkV.js +0 -1
  88. package/dist/web/assets/keybindings-store-Djjc6tPj.js +0 -1
  89. package/dist/web/assets/packet-RMMSAZCW-DMi06dVb.js +0 -1
  90. package/dist/web/assets/pie-UPGHQEXC-BECm43s6.js +0 -1
  91. package/dist/web/assets/port-forwarding-tab-DfaV6GPS.js +0 -1
  92. package/dist/web/assets/radar-KQ55EAFF-BwqCptkx.js +0 -1
  93. package/dist/web/assets/settings-tab-BFbe6ybw.js +0 -1
  94. package/dist/web/assets/sqlite-viewer-8oWf4JCB.js +0 -1
  95. package/dist/web/assets/terminal-tab-D_C7amDZ.js +0 -1
  96. package/dist/web/assets/treemap-KZPCXAKY-Da7U3Olf.js +0 -1
  97. /package/dist/web/assets/{api-client-Dvzcc_EO.js → api-client-DIhJ5qVW.js} +0 -0
  98. /package/dist/web/assets/{data-grid-types-BTQHYBUh.js → data-grid-types-DqqspyVw.js} +0 -0
  99. /package/dist/web/assets/{dist-0kPgRaVx.js → dist-D1SZxtVS.js} +0 -0
  100. /package/dist/web/assets/{dist-DGSkE2Ml.js → dist-_jZs3YZC.js} +0 -0
  101. /package/dist/web/assets/{file-exclamation-point-Baz81y5z.js → file-exclamation-point-BwzaQ50n.js} +0 -0
  102. /package/dist/web/assets/{katex-BuytEdO1.js → katex-DzXRfQ_m.js} +0 -0
  103. /package/dist/web/assets/{lib-DQHnkzGy.js → lib-Dub8DlCJ.js} +0 -0
  104. /package/dist/web/assets/{react-GqWghJ-L.js → react-DMIOAtcX.js} +0 -0
  105. /package/dist/web/assets/{refresh-cw-LlbZDJpO.js → refresh-cw-BjrAbUJe.js} +0 -0
  106. /package/dist/web/assets/{sparkles-fWUT5Vzq.js → sparkles-CulWHe4c.js} +0 -0
  107. /package/dist/web/assets/{table-tf7pRkME.js → table-BzjWcs87.js} +0 -0
  108. /package/dist/web/assets/{text-wrap-BV-R4Vvy.js → text-wrap-DJz9Bgpa.js} +0 -0
  109. /package/dist/web/assets/{utils-CTg5uAYR.js → utils-CQux7CsO.js} +0 -0
  110. /package/dist/web/assets/{vendor-xterm-CU2c3f0A.js → vendor-xterm-Dyfw49hJ.js} +0 -0
  111. /package/dist/web/assets/{x-CG-_0yIW.js → x-BPReZWnP.js} +0 -0
@@ -1,119 +1,13 @@
1
- import { useState, useEffect, useCallback } from "react";
2
- import { Bot, ChevronDown, ChevronUp, MessageSquare, Pin, PinOff } from "lucide-react";
3
- import { api, projectUrl } from "@/lib/api-client";
4
- import { formatRelativeDate } from "@/lib/format-date";
5
- import { useProjectTags, TagChipBar } from "./tag-filter-chips";
6
- import { SessionContextMenu } from "./session-context-menu";
1
+ import { Bot } from "lucide-react";
2
+ import { SessionListPanel } from "./session-list-panel";
7
3
  import type { SessionInfo } from "../../../types/chat";
8
4
 
9
- const MAX_RECENT_SESSIONS = 5;
10
- const FETCH_SESSIONS_LIMIT = 20;
11
-
12
5
  interface ChatWelcomeProps {
13
6
  projectName: string;
14
7
  onSelectSession: (session: SessionInfo) => void;
15
8
  }
16
9
 
17
10
  export function ChatWelcome({ projectName, onSelectSession }: ChatWelcomeProps) {
18
- const [sessions, setSessions] = useState<SessionInfo[]>([]);
19
- const [loading, setLoading] = useState(false);
20
- const [showAll, setShowAll] = useState(false);
21
- const [selectedTagId, setSelectedTagId] = useState<number | null>(null);
22
- const { projectTags, tagCounts, loadTags } = useProjectTags(projectName);
23
-
24
- const loadSessions = useCallback(async () => {
25
- if (!projectName) return;
26
- setLoading(true);
27
- try {
28
- const data = await api.get<{ sessions: SessionInfo[]; hasMore: boolean }>(`${projectUrl(projectName)}/chat/sessions?limit=${FETCH_SESSIONS_LIMIT}`);
29
- setSessions(data.sessions.slice(0, FETCH_SESSIONS_LIMIT));
30
- } catch {
31
- // silently ignore
32
- } finally {
33
- setLoading(false);
34
- }
35
- }, [projectName]);
36
-
37
- useEffect(() => { loadSessions(); }, [loadSessions]);
38
-
39
- const togglePin = useCallback(async (e: React.MouseEvent, session: SessionInfo) => {
40
- e.stopPropagation();
41
- if (!projectName) return;
42
- const url = `${projectUrl(projectName)}/chat/sessions/${session.id}/pin`;
43
- try {
44
- if (session.pinned) {
45
- await api.del(url);
46
- } else {
47
- await api.put(url);
48
- }
49
- setSessions((prev) => {
50
- const updated = prev.map((s) => s.id === session.id ? { ...s, pinned: !s.pinned } : s);
51
- return updated.sort((a, b) => {
52
- if (a.pinned && !b.pinned) return -1;
53
- if (!a.pinned && b.pinned) return 1;
54
- return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
55
- });
56
- });
57
- } catch {
58
- // silently ignore
59
- }
60
- }, [projectName]);
61
-
62
- const handleTagChanged = useCallback((sid: string, tag: { id: number; name: string; color: string } | null) => {
63
- setSessions((prev) => prev.map((s) => s.id === sid ? { ...s, tag } : s));
64
- loadTags();
65
- }, [loadTags]);
66
-
67
- const filtered = selectedTagId !== null ? sessions.filter((s) => s.tag?.id === selectedTagId) : sessions;
68
- const pinnedSessions = filtered.filter((s) => s.pinned);
69
- const allRecentSessions = filtered.filter((s) => !s.pinned);
70
- const recentSessions = showAll ? allRecentSessions : allRecentSessions.slice(0, MAX_RECENT_SESSIONS);
71
- const hasMore = allRecentSessions.length > MAX_RECENT_SESSIONS;
72
-
73
- function renderSessionRow(session: SessionInfo) {
74
- return (
75
- <SessionContextMenu
76
- key={session.id}
77
- session={session}
78
- projectName={projectName}
79
- projectTags={projectTags}
80
- onTogglePin={togglePin}
81
- onTagChanged={handleTagChanged}
82
- >
83
- <button
84
- onClick={() => onSelectSession(session)}
85
- className="group flex items-center gap-2.5 w-full px-3 py-2.5 text-left hover:bg-surface-elevated active:bg-surface-elevated transition-colors border-b border-border/50 last:border-0"
86
- >
87
- <MessageSquare className="size-3.5 shrink-0 text-text-subtle" />
88
- {session.tag && (
89
- <span className="size-2 rounded-full shrink-0" style={{ backgroundColor: session.tag.color }} title={session.tag.name} />
90
- )}
91
- <span className="flex-1 min-w-0 text-xs font-medium truncate text-text-primary">
92
- {session.title || "Untitled"}
93
- </span>
94
- {session.updatedAt && (
95
- <span className="text-[10px] text-text-subtle shrink-0">
96
- {formatRelativeDate(session.updatedAt)}
97
- </span>
98
- )}
99
- <span
100
- role="button"
101
- tabIndex={0}
102
- onClick={(e) => togglePin(e, session)}
103
- className={`p-1 rounded transition-colors shrink-0 ${
104
- session.pinned
105
- ? "text-primary hover:text-primary/70"
106
- : "text-text-subtle can-hover:opacity-0 can-hover:group-hover:opacity-100 hover:text-text-primary"
107
- }`}
108
- aria-label={session.pinned ? "Unpin session" : "Pin session"}
109
- >
110
- {session.pinned ? <PinOff className="size-3" /> : <Pin className="size-3" />}
111
- </span>
112
- </button>
113
- </SessionContextMenu>
114
- );
115
- }
116
-
117
11
  return (
118
12
  <div className="flex flex-col items-center justify-center h-full gap-6 text-text-secondary overflow-y-auto">
119
13
  <div className="flex flex-col items-center gap-3">
@@ -121,38 +15,11 @@ export function ChatWelcome({ projectName, onSelectSession }: ChatWelcomeProps)
121
15
  <p className="text-sm">Send a message to start a new conversation</p>
122
16
  </div>
123
17
 
124
- {!loading && sessions.length > 0 && (
125
- <div className="w-full max-w-sm px-4">
126
- <TagChipBar projectTags={projectTags} tagCounts={tagCounts} totalCount={sessions.length} selectedTagId={selectedTagId} onSelect={setSelectedTagId} />
127
- </div>
128
- )}
129
-
130
- {!loading && pinnedSessions.length > 0 && (
131
- <div className="flex flex-col gap-2 w-full max-w-sm px-4">
132
- <p className="text-xs text-text-subtle text-center">Pinned</p>
133
- <div className="w-full rounded-md border border-border bg-surface overflow-hidden">
134
- {pinnedSessions.map(renderSessionRow)}
135
- </div>
136
- </div>
137
- )}
138
-
139
- {!loading && recentSessions.length > 0 && (
140
- <div className="flex flex-col gap-2 w-full max-w-sm px-4">
141
- <p className="text-xs text-text-subtle text-center">Recent chats</p>
142
- <div className="w-full rounded-md border border-border bg-surface overflow-hidden">
143
- {recentSessions.map(renderSessionRow)}
144
- </div>
145
- {hasMore && (
146
- <button
147
- onClick={() => setShowAll(!showAll)}
148
- className="flex items-center justify-center gap-1 text-[11px] text-text-subtle hover:text-text-primary transition-colors py-1"
149
- >
150
- {showAll ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />}
151
- {showAll ? "Show less" : `Show more (${allRecentSessions.length - MAX_RECENT_SESSIONS})`}
152
- </button>
153
- )}
154
- </div>
155
- )}
18
+ <SessionListPanel
19
+ projectName={projectName}
20
+ onSelectSession={onSelectSession}
21
+ className="w-full px-4"
22
+ />
156
23
  </div>
157
24
  );
158
25
  }
@@ -0,0 +1,188 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { ChevronDown, ChevronUp, MessageSquare, Pin, PinOff, Search, X } from "lucide-react";
3
+ import { api, projectUrl } from "@/lib/api-client";
4
+ import { formatRelativeDate } from "@/lib/format-date";
5
+ import { useProjectTags, TagChipBar } from "./tag-filter-chips";
6
+ import { SessionContextMenu } from "./session-context-menu";
7
+ import type { SessionInfo, ProjectTag } from "../../../types/chat";
8
+
9
+ const MAX_RECENT_SESSIONS = 5;
10
+ const FETCH_SESSIONS_LIMIT = 20;
11
+
12
+ interface SessionListPanelProps {
13
+ projectName: string | undefined;
14
+ onSelectSession: (session: SessionInfo) => void;
15
+ className?: string;
16
+ }
17
+
18
+ export function SessionListPanel({ projectName, onSelectSession, className }: SessionListPanelProps) {
19
+ const [sessions, setSessions] = useState<SessionInfo[]>([]);
20
+ const [loading, setLoading] = useState(false);
21
+ const [showAll, setShowAll] = useState(false);
22
+ const [searchQuery, setSearchQuery] = useState("");
23
+ const [selectedTagId, setSelectedTagId] = useState<number | null>(null);
24
+ const { projectTags, tagCounts, loadTags } = useProjectTags(projectName);
25
+
26
+ const loadSessions = useCallback(async () => {
27
+ if (!projectName) return;
28
+ setLoading(true);
29
+ try {
30
+ const data = await api.get<{ sessions: SessionInfo[]; hasMore: boolean }>(`${projectUrl(projectName)}/chat/sessions?limit=${FETCH_SESSIONS_LIMIT}`);
31
+ setSessions(data.sessions.slice(0, FETCH_SESSIONS_LIMIT));
32
+ } catch {
33
+ // silently ignore
34
+ } finally {
35
+ setLoading(false);
36
+ }
37
+ }, [projectName]);
38
+
39
+ useEffect(() => { loadSessions(); }, [loadSessions]);
40
+
41
+ const togglePin = useCallback(async (e: React.MouseEvent, session: SessionInfo) => {
42
+ e.stopPropagation();
43
+ if (!projectName) return;
44
+ const url = `${projectUrl(projectName)}/chat/sessions/${session.id}/pin`;
45
+ try {
46
+ if (session.pinned) {
47
+ await api.del(url);
48
+ } else {
49
+ await api.put(url);
50
+ }
51
+ setSessions((prev) => {
52
+ const updated = prev.map((s) => s.id === session.id ? { ...s, pinned: !s.pinned } : s);
53
+ return updated.sort((a, b) => {
54
+ if (a.pinned && !b.pinned) return -1;
55
+ if (!a.pinned && b.pinned) return 1;
56
+ return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
57
+ });
58
+ });
59
+ } catch {
60
+ // silently ignore
61
+ }
62
+ }, [projectName]);
63
+
64
+ const handleTagChanged = useCallback((sid: string, tag: { id: number; name: string; color: string } | null) => {
65
+ setSessions((prev) => prev.map((s) => s.id === sid ? { ...s, tag } : s));
66
+ loadTags();
67
+ }, [loadTags]);
68
+
69
+ const query = searchQuery.toLowerCase().trim();
70
+ const filtered = sessions.filter((s) => {
71
+ if (selectedTagId !== null && s.tag?.id !== selectedTagId) return false;
72
+ if (query && !(s.title || "").toLowerCase().includes(query)) return false;
73
+ return true;
74
+ });
75
+ const pinnedSessions = filtered.filter((s) => s.pinned);
76
+ const allRecentSessions = filtered.filter((s) => !s.pinned);
77
+ const recentSessions = showAll ? allRecentSessions : allRecentSessions.slice(0, MAX_RECENT_SESSIONS);
78
+ const hasMore = allRecentSessions.length > MAX_RECENT_SESSIONS;
79
+
80
+ if (loading || !projectName || sessions.length === 0) return null;
81
+
82
+ return (
83
+ <div className={className}>
84
+ <div className="relative">
85
+ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-text-subtle pointer-events-none" />
86
+ <input
87
+ type="text"
88
+ value={searchQuery}
89
+ onChange={(e) => setSearchQuery(e.target.value)}
90
+ placeholder="Search chats..."
91
+ className="w-full pl-8 pr-8 py-1.5 text-xs rounded-md border border-border bg-surface text-text-primary placeholder:text-text-subtle focus:outline-none focus:ring-1 focus:ring-primary/50"
92
+ />
93
+ {searchQuery && (
94
+ <button onClick={() => setSearchQuery("")} className="absolute right-2 top-1/2 -translate-y-1/2 text-text-subtle hover:text-text-primary">
95
+ <X className="size-3.5" />
96
+ </button>
97
+ )}
98
+ </div>
99
+
100
+ <div className="mt-3">
101
+ <TagChipBar projectTags={projectTags} tagCounts={tagCounts} totalCount={sessions.length} selectedTagId={selectedTagId} onSelect={setSelectedTagId} />
102
+ </div>
103
+
104
+ {pinnedSessions.length > 0 && (
105
+ <div className="flex flex-col gap-2 w-full mt-4">
106
+ <p className="text-xs text-text-subtle text-center">Pinned</p>
107
+ <div className="w-full rounded-md border border-border bg-surface overflow-hidden">
108
+ {pinnedSessions.map((s) => (
109
+ <SessionRow key={s.id} session={s} projectName={projectName} projectTags={projectTags} onSelect={onSelectSession} onTogglePin={togglePin} onTagChanged={handleTagChanged} />
110
+ ))}
111
+ </div>
112
+ </div>
113
+ )}
114
+
115
+ {recentSessions.length > 0 && (
116
+ <div className="flex flex-col gap-2 w-full mt-4">
117
+ <p className="text-xs text-text-subtle text-center">Recent chats</p>
118
+ <div className="w-full rounded-md border border-border bg-surface overflow-hidden">
119
+ {recentSessions.map((s) => (
120
+ <SessionRow key={s.id} session={s} projectName={projectName} projectTags={projectTags} onSelect={onSelectSession} onTogglePin={togglePin} onTagChanged={handleTagChanged} />
121
+ ))}
122
+ </div>
123
+ {hasMore && (
124
+ <button
125
+ onClick={() => setShowAll(!showAll)}
126
+ className="flex items-center justify-center gap-1 text-[11px] text-text-subtle hover:text-text-primary transition-colors py-1"
127
+ >
128
+ {showAll ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />}
129
+ {showAll ? "Show less" : `Show more (${allRecentSessions.length - MAX_RECENT_SESSIONS})`}
130
+ </button>
131
+ )}
132
+ </div>
133
+ )}
134
+ </div>
135
+ );
136
+ }
137
+
138
+ interface SessionRowProps {
139
+ session: SessionInfo;
140
+ projectName: string;
141
+ projectTags: ProjectTag[];
142
+ onSelect: (session: SessionInfo) => void;
143
+ onTogglePin: (e: React.MouseEvent, session: SessionInfo) => void;
144
+ onTagChanged: (sid: string, tag: { id: number; name: string; color: string } | null) => void;
145
+ }
146
+
147
+ function SessionRow({ session, projectName, projectTags, onSelect, onTogglePin, onTagChanged }: SessionRowProps) {
148
+ return (
149
+ <SessionContextMenu
150
+ session={session}
151
+ projectName={projectName}
152
+ projectTags={projectTags}
153
+ onTogglePin={onTogglePin}
154
+ onTagChanged={onTagChanged}
155
+ >
156
+ <button
157
+ onClick={() => onSelect(session)}
158
+ className="group flex items-center gap-2.5 w-full px-3 py-2.5 text-left hover:bg-surface-elevated active:bg-surface-elevated transition-colors border-b border-border/50 last:border-0"
159
+ >
160
+ <MessageSquare className="size-3.5 shrink-0 text-text-subtle" />
161
+ {session.tag && (
162
+ <span className="size-2 rounded-full shrink-0" style={{ backgroundColor: session.tag.color }} title={session.tag.name} />
163
+ )}
164
+ <span className="flex-1 min-w-0 text-xs font-medium truncate text-text-primary">
165
+ {session.title || "Untitled"}
166
+ </span>
167
+ {session.updatedAt && (
168
+ <span className="text-[10px] text-text-subtle shrink-0">
169
+ {formatRelativeDate(session.updatedAt)}
170
+ </span>
171
+ )}
172
+ <span
173
+ role="button"
174
+ tabIndex={0}
175
+ onClick={(e) => onTogglePin(e, session)}
176
+ className={`p-1 rounded transition-colors shrink-0 ${
177
+ session.pinned
178
+ ? "text-primary hover:text-primary/70"
179
+ : "text-text-subtle can-hover:opacity-0 can-hover:group-hover:opacity-100 hover:text-text-primary"
180
+ }`}
181
+ aria-label={session.pinned ? "Unpin session" : "Pin session"}
182
+ >
183
+ {session.pinned ? <PinOff className="size-3" /> : <Pin className="size-3" />}
184
+ </span>
185
+ </button>
186
+ </SessionContextMenu>
187
+ );
188
+ }
@@ -4,7 +4,7 @@ import { api, projectUrl } from "@/lib/api-client";
4
4
  import { useShallow } from "zustand/react/shallow";
5
5
  import { useSettingsStore } from "@/stores/settings-store";
6
6
  import { useMonacoTheme } from "@/lib/use-monaco-theme";
7
- import { Loader2, FileCode, PanelLeftOpen, PanelRightOpen, Columns2, WrapText } from "lucide-react";
7
+ import { Loader2, FileCode, WrapText } from "lucide-react";
8
8
 
9
9
  function getMonacoLanguage(filename: string): string {
10
10
  const ext = filename.split(".").pop()?.toLowerCase() ?? "";
@@ -41,12 +41,13 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
41
41
  const [fullFileDiff, setFullFileDiff] = useState<{ original: string; modified: string } | null>(null);
42
42
  const [loading, setLoading] = useState(!isInline);
43
43
  const [error, setError] = useState<string | null>(null);
44
- const [expandMode, setExpandMode] = useState<"both" | "left" | "right">("both");
45
44
  const { wordWrap, toggleWordWrap } = useSettingsStore(useShallow((s) => ({ wordWrap: s.wordWrap, toggleWordWrap: s.toggleWordWrap })));
46
45
  const monacoTheme = useMonacoTheme();
47
46
 
48
47
  // Measure container height — Monaco needs explicit pixel height on mobile
49
48
  const containerRef = useRef<HTMLDivElement>(null);
49
+ const diffEditorRef = useRef<import("monaco-editor").editor.IStandaloneDiffEditor | null>(null);
50
+ const [editorReady, setEditorReady] = useState(false);
50
51
  const [containerHeight, setContainerHeight] = useState<number | undefined>();
51
52
 
52
53
  useEffect(() => {
@@ -125,7 +126,22 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
125
126
 
126
127
  // Force inline on mobile (<768px) since side-by-side is too narrow
127
128
  const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
128
- const renderSideBySide = !isMobile && expandMode === "both";
129
+ const renderSideBySide = !isMobile;
130
+
131
+ // Sync word wrap on both sub-editors.
132
+ // Monaco DiffEditor has a bug: during init when container width is 0,
133
+ // useInlineViewWhenSpaceIsLimited briefly triggers inline mode which sets
134
+ // wordWrapOverride2='off' on the original editor. When side-by-side resumes,
135
+ // wordWrapOverride2 is never cleared, permanently blocking word wrap on the
136
+ // left side. We disable that option and also force wordWrapOverride2 to clear it.
137
+ useEffect(() => {
138
+ const editor = diffEditorRef.current;
139
+ if (!editor) return;
140
+ const val: "on" | "off" = isMobile ? "on" : wordWrap ? "on" : "off";
141
+ editor.updateOptions({ diffWordWrap: val });
142
+ editor.getOriginalEditor().updateOptions({ wordWrapOverride2: val } as any);
143
+ editor.getModifiedEditor().updateOptions({ wordWrapOverride2: val } as any);
144
+ }, [wordWrap, isMobile, editorReady]);
129
145
 
130
146
  if (!projectName && !isInline) {
131
147
  return (
@@ -166,28 +182,6 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
166
182
  {/* Toolbar */}
167
183
  {!isMobile && (
168
184
  <div className="flex items-center justify-end gap-0.5 px-2 py-0.5 border-b border-border shrink-0">
169
- <button type="button"
170
- onClick={() => setExpandMode(expandMode === "left" ? "both" : "left")}
171
- className={`p-1 rounded hover:bg-muted transition-colors ${expandMode === "left" ? "bg-muted text-foreground" : ""}`}
172
- title="Expand original"
173
- >
174
- <PanelLeftOpen className="size-3.5" />
175
- </button>
176
- <button type="button"
177
- onClick={() => setExpandMode("both")}
178
- className={`p-1 rounded hover:bg-muted transition-colors ${expandMode === "both" ? "bg-muted text-foreground" : ""}`}
179
- title="Side by side"
180
- >
181
- <Columns2 className="size-3.5" />
182
- </button>
183
- <button type="button"
184
- onClick={() => setExpandMode(expandMode === "right" ? "both" : "right")}
185
- className={`p-1 rounded hover:bg-muted transition-colors ${expandMode === "right" ? "bg-muted text-foreground" : ""}`}
186
- title="Expand modified"
187
- >
188
- <PanelRightOpen className="size-3.5" />
189
- </button>
190
- <div className="w-px h-3.5 bg-border mx-0.5 shrink-0" />
191
185
  <button type="button" onClick={toggleWordWrap} title="Toggle word wrap"
192
186
  className={`p-1 rounded hover:bg-muted transition-colors ${wordWrap ? "bg-muted text-foreground" : ""}`}
193
187
  >
@@ -204,11 +198,16 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
204
198
  original={original}
205
199
  modified={modified}
206
200
  theme={monacoTheme}
201
+ onMount={(editor) => {
202
+ diffEditorRef.current = editor;
203
+ setEditorReady(true);
204
+ }}
207
205
  options={{
208
206
  fontSize: isMobile ? 11 : 13,
209
207
  fontFamily: "Menlo, Monaco, Consolas, monospace",
210
- wordWrap: isMobile ? "on" : wordWrap ? "on" : "off",
208
+ diffWordWrap: isMobile ? "on" : wordWrap ? "on" : "off",
211
209
  renderSideBySide,
210
+ useInlineViewWhenSpaceIsLimited: false,
212
211
  readOnly: true,
213
212
  automaticLayout: true,
214
213
  scrollBeyondLastLine: false,
@@ -1,11 +1,9 @@
1
- import { Suspense, lazy, useEffect, useState, useCallback } from "react";
2
- import { ChevronDown, ChevronUp, Loader2, Terminal, MessageSquare, FilePlus, Pin, PinOff } from "lucide-react";
1
+ import { Suspense, lazy, useCallback } from "react";
2
+ import { Loader2, Terminal, MessageSquare, FilePlus } from "lucide-react";
3
3
  import { usePanelStore } from "@/stores/panel-store";
4
4
  import { useProjectStore } from "@/stores/project-store";
5
5
  import { useTabStore, type TabType } from "@/stores/tab-store";
6
- import { api, projectUrl } from "@/lib/api-client";
7
- import { useProjectTags, TagChipBar } from "@/components/chat/tag-filter-chips";
8
- import { SessionContextMenu } from "@/components/chat/session-context-menu";
6
+ import { SessionListPanel } from "@/components/chat/session-list-panel";
9
7
  import type { SessionInfo } from "../../../types/chat";
10
8
  import { TabBar } from "./tab-bar";
11
9
  import { SplitDropOverlay } from "./split-drop-overlay";
@@ -87,72 +85,8 @@ export function EditorPanel({ panelId, projectName }: EditorPanelProps) {
87
85
  );
88
86
  }
89
87
 
90
- function formatRelativeDate(iso: string): string {
91
- try {
92
- const date = new Date(iso);
93
- const now = new Date();
94
- const diffMs = now.getTime() - date.getTime();
95
- const diffMin = Math.floor(diffMs / 60_000);
96
- if (diffMin < 1) return "Just now";
97
- if (diffMin < 60) return `${diffMin}m ago`;
98
- const diffHr = Math.floor(diffMin / 60);
99
- if (diffHr < 24) return `${diffHr}h ago`;
100
- const diffDay = Math.floor(diffHr / 24);
101
- if (diffDay < 7) return `${diffDay}d ago`;
102
- return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
103
- } catch {
104
- return "";
105
- }
106
- }
107
-
108
- const MAX_RECENT_SESSIONS = 5;
109
- const FETCH_SESSIONS_LIMIT = 20;
110
-
111
88
  function EmptyPanel({ panelId }: { panelId: string }) {
112
89
  const activeProject = useProjectStore((s) => s.activeProject);
113
- const [sessions, setSessions] = useState<SessionInfo[]>([]);
114
- const [loadingSessions, setLoadingSessions] = useState(false);
115
- const [showAll, setShowAll] = useState(false);
116
- const [selectedTagId, setSelectedTagId] = useState<number | null>(null);
117
- const { projectTags, tagCounts, loadTags } = useProjectTags(activeProject?.name);
118
-
119
- const loadSessions = useCallback(async () => {
120
- if (!activeProject?.name) return;
121
- setLoadingSessions(true);
122
- try {
123
- const data = await api.get<{ sessions: SessionInfo[]; hasMore: boolean }>(`${projectUrl(activeProject.name)}/chat/sessions?limit=${FETCH_SESSIONS_LIMIT}`);
124
- setSessions(data.sessions.slice(0, FETCH_SESSIONS_LIMIT));
125
- } catch {
126
- // silently ignore — empty state still functional without sessions
127
- } finally {
128
- setLoadingSessions(false);
129
- }
130
- }, [activeProject?.name]);
131
-
132
- useEffect(() => { loadSessions(); }, [loadSessions]);
133
-
134
- const togglePin = useCallback(async (e: React.MouseEvent, session: SessionInfo) => {
135
- e.stopPropagation();
136
- if (!activeProject?.name) return;
137
- const url = `${projectUrl(activeProject.name)}/chat/sessions/${session.id}/pin`;
138
- try {
139
- if (session.pinned) {
140
- await api.del(url);
141
- } else {
142
- await api.put(url);
143
- }
144
- setSessions((prev) => {
145
- const updated = prev.map((s) => s.id === session.id ? { ...s, pinned: !s.pinned } : s);
146
- return updated.sort((a, b) => {
147
- if (a.pinned && !b.pinned) return -1;
148
- if (!a.pinned && b.pinned) return 1;
149
- return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
150
- });
151
- });
152
- } catch {
153
- // silently ignore
154
- }
155
- }, [activeProject?.name]);
156
90
 
157
91
  function openTab(type: TabType) {
158
92
  if (type === "editor") {
@@ -167,7 +101,7 @@ function EmptyPanel({ panelId }: { panelId: string }) {
167
101
  );
168
102
  }
169
103
 
170
- function openSession(session: SessionInfo) {
104
+ const openSession = useCallback((session: SessionInfo) => {
171
105
  usePanelStore.getState().openTab(
172
106
  {
173
107
  type: "chat",
@@ -178,62 +112,7 @@ function EmptyPanel({ panelId }: { panelId: string }) {
178
112
  },
179
113
  panelId,
180
114
  );
181
- }
182
-
183
- const handleTagChanged = useCallback((sid: string, tag: { id: number; name: string; color: string } | null) => {
184
- setSessions((prev) => prev.map((s) => s.id === sid ? { ...s, tag } : s));
185
- loadTags();
186
- }, [loadTags]);
187
-
188
- const filtered = selectedTagId !== null ? sessions.filter((s) => s.tag?.id === selectedTagId) : sessions;
189
- const pinnedSessions = filtered.filter((s) => s.pinned);
190
- const allRecentSessions = filtered.filter((s) => !s.pinned);
191
- const recentSessions = showAll ? allRecentSessions : allRecentSessions.slice(0, MAX_RECENT_SESSIONS);
192
- const hasMore = allRecentSessions.length > MAX_RECENT_SESSIONS;
193
-
194
- function renderSessionRow(session: SessionInfo) {
195
- return (
196
- <SessionContextMenu
197
- key={session.id}
198
- session={session}
199
- projectName={activeProject!.name}
200
- projectTags={projectTags}
201
- onTogglePin={togglePin}
202
- onTagChanged={handleTagChanged}
203
- >
204
- <button
205
- onClick={() => openSession(session)}
206
- className="group flex items-center gap-2.5 w-full px-3 py-2.5 text-left hover:bg-surface-elevated active:bg-surface-elevated transition-colors border-b border-border/50 last:border-0"
207
- >
208
- <MessageSquare className="size-3.5 shrink-0 text-text-subtle" />
209
- {session.tag && (
210
- <span className="size-2 rounded-full shrink-0" style={{ backgroundColor: session.tag.color }} title={session.tag.name} />
211
- )}
212
- <span className="flex-1 min-w-0 text-xs font-medium truncate text-text-primary">
213
- {session.title || "Untitled"}
214
- </span>
215
- {session.updatedAt && (
216
- <span className="text-[10px] text-text-subtle shrink-0">
217
- {formatRelativeDate(session.updatedAt)}
218
- </span>
219
- )}
220
- <span
221
- role="button"
222
- tabIndex={0}
223
- onClick={(e) => togglePin(e, session)}
224
- className={`p-1 rounded transition-colors shrink-0 ${
225
- session.pinned
226
- ? "text-primary hover:text-primary/70"
227
- : "text-text-subtle can-hover:opacity-0 can-hover:group-hover:opacity-100 hover:text-text-primary"
228
- }`}
229
- aria-label={session.pinned ? "Unpin session" : "Pin session"}
230
- >
231
- {session.pinned ? <PinOff className="size-3" /> : <Pin className="size-3" />}
232
- </span>
233
- </button>
234
- </SessionContextMenu>
235
- );
236
- }
115
+ }, [activeProject?.name, panelId]);
237
116
 
238
117
  return (
239
118
  <div className="flex flex-col h-full overflow-y-auto text-text-secondary">
@@ -255,38 +134,11 @@ function EmptyPanel({ panelId }: { panelId: string }) {
255
134
  })}
256
135
  </div>
257
136
 
258
- {activeProject && !loadingSessions && sessions.length > 0 && (
259
- <div className="w-full max-w-sm">
260
- <TagChipBar projectTags={projectTags} tagCounts={tagCounts} totalCount={sessions.length} selectedTagId={selectedTagId} onSelect={setSelectedTagId} />
261
- </div>
262
- )}
263
-
264
- {activeProject && !loadingSessions && pinnedSessions.length > 0 && (
265
- <div className="flex flex-col gap-2 w-full max-w-sm">
266
- <p className="text-xs text-text-subtle text-center">Pinned</p>
267
- <div className="w-full rounded-md border border-border bg-surface overflow-hidden">
268
- {pinnedSessions.map(renderSessionRow)}
269
- </div>
270
- </div>
271
- )}
272
-
273
- {activeProject && !loadingSessions && recentSessions.length > 0 && (
274
- <div className="flex flex-col gap-2 w-full max-w-sm">
275
- <p className="text-xs text-text-subtle text-center">Recent chats</p>
276
- <div className="w-full rounded-md border border-border bg-surface overflow-hidden">
277
- {recentSessions.map(renderSessionRow)}
278
- </div>
279
- {hasMore && (
280
- <button
281
- onClick={() => setShowAll(!showAll)}
282
- className="flex items-center justify-center gap-1 text-[11px] text-text-subtle hover:text-text-primary transition-colors py-1"
283
- >
284
- {showAll ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />}
285
- {showAll ? "Show less" : `Show more (${allRecentSessions.length - MAX_RECENT_SESSIONS})`}
286
- </button>
287
- )}
288
- </div>
289
- )}
137
+ <SessionListPanel
138
+ projectName={activeProject?.name}
139
+ onSelectSession={openSession}
140
+ className="w-full"
141
+ />
290
142
  </div>
291
143
  </div>
292
144
  );