@tt-a1i/openpi 0.6.1 → 0.7.0
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.
- package/THIRD_PARTY_NOTICES.md +242 -0
- package/extensions/ai-providers/cursor/connect-frame-reader.ts +76 -0
- package/extensions/ai-providers/cursor/provider.ts +6 -19
- package/extensions/file-mutation-display/index.ts +13 -9
- package/extensions/shared/agent-transcript.ts +3 -2
- package/extensions/web/index.ts +69 -5
- package/extensions/workflows/artifacts.ts +362 -23
- package/extensions/workflows/dashboard.ts +2 -0
- package/package.json +28 -4
- package/web/dist/app.js +87 -0
- package/web/dist/favicon.svg +9 -0
- package/web/dist/index.html +15 -0
- package/web/dist/styles.css +3 -0
- package/web/host/web-host.ts +6 -9
- package/web/ui/index.html +3 -131
- package/web/ui/public/favicon.svg +9 -0
- package/web/ui/src/app/App.tsx +134 -0
- package/web/ui/src/app/providers.tsx +38 -0
- package/web/ui/src/components/Markdown.tsx +58 -0
- package/web/ui/src/components/OpenPiLogo.tsx +41 -0
- package/web/ui/src/features/activity/ActivityBar.tsx +120 -0
- package/web/ui/src/features/composer/Composer.tsx +237 -0
- package/web/ui/src/features/sessions/SessionSidebar.tsx +418 -0
- package/web/ui/src/features/transcript/Transcript.tsx +860 -0
- package/web/ui/src/i18n.ts +159 -0
- package/web/ui/src/lib/format.ts +57 -0
- package/web/ui/src/main.tsx +16 -0
- package/web/ui/src/protocol/client.ts +199 -0
- package/web/ui/src/protocol/event-stream.ts +88 -0
- package/web/ui/src/store/web-store.ts +926 -0
- package/web/ui/src/styles.css +420 -0
- package/web/ui/tsconfig.json +12 -0
- package/web/ui/vite-env.d.ts +1 -0
- package/web/vite.config.mjs +21 -1
- package/web/host/static-assets.ts +0 -4
- package/web/ui/app.js +0 -1700
- package/web/ui/styles.css +0 -680
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { DropdownMenu } from "@astryxdesign/core/DropdownMenu";
|
|
2
|
+
import { Tooltip } from "@astryxdesign/core/Tooltip";
|
|
3
|
+
import { Check, ChevronDown, Folder, Plus, Send, Square } from "lucide-react";
|
|
4
|
+
import { type FormEvent, useRef, useState } from "react";
|
|
5
|
+
import { useTranslation } from "react-i18next";
|
|
6
|
+
import type { WebSnapshot } from "../../../../protocol/types.ts";
|
|
7
|
+
import { workspaceName } from "../../lib/format.ts";
|
|
8
|
+
import type { WebStoreActions, WebStoreState } from "../../store/web-store.ts";
|
|
9
|
+
import { ActivityBar } from "../activity/ActivityBar.tsx";
|
|
10
|
+
|
|
11
|
+
interface ComposerProps {
|
|
12
|
+
snapshot: WebSnapshot | null;
|
|
13
|
+
selectedWorkspace: string | null;
|
|
14
|
+
sessionSwitching: boolean;
|
|
15
|
+
promptAdmissionPending: boolean;
|
|
16
|
+
liveRunning: boolean;
|
|
17
|
+
landing: boolean;
|
|
18
|
+
actions: WebStoreActions;
|
|
19
|
+
activeTurn: WebStoreState["activeTurn"];
|
|
20
|
+
turnCancellationPending: boolean;
|
|
21
|
+
turnTerminalStatus: string | null;
|
|
22
|
+
pendingFollowUpsReceipt: number | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function Composer(props: ComposerProps) {
|
|
26
|
+
const { t } = useTranslation();
|
|
27
|
+
const [prompt, setPrompt] = useState("");
|
|
28
|
+
const textarea = useRef<HTMLTextAreaElement>(null);
|
|
29
|
+
const selected = props.snapshot?.selectedSession;
|
|
30
|
+
const active = Boolean(
|
|
31
|
+
selected?.id && selected.id === props.snapshot?.currentSessionId,
|
|
32
|
+
);
|
|
33
|
+
const draftSession = Boolean(
|
|
34
|
+
props.selectedWorkspace && !selected && !props.snapshot?.currentSessionId,
|
|
35
|
+
);
|
|
36
|
+
const canCompose = active || draftSession;
|
|
37
|
+
const running =
|
|
38
|
+
props.snapshot?.runtime.status === "running" || props.liveRunning;
|
|
39
|
+
const canStop =
|
|
40
|
+
active &&
|
|
41
|
+
running &&
|
|
42
|
+
Boolean(props.activeTurn ?? props.snapshot?.runtime.activeTurn);
|
|
43
|
+
const disabled =
|
|
44
|
+
props.sessionSwitching || (!canCompose && Boolean(props.selectedWorkspace));
|
|
45
|
+
|
|
46
|
+
const resize = (element: HTMLTextAreaElement) => {
|
|
47
|
+
element.style.height = "auto";
|
|
48
|
+
element.style.height = `${Math.min(element.scrollHeight, 220)}px`;
|
|
49
|
+
element.style.overflowY = element.scrollHeight > 220 ? "auto" : "hidden";
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const send = async (event?: FormEvent) => {
|
|
53
|
+
event?.preventDefault();
|
|
54
|
+
if (!props.selectedWorkspace) {
|
|
55
|
+
await props.actions.chooseWorkspace();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (await props.actions.sendPrompt(prompt)) {
|
|
59
|
+
setPrompt("");
|
|
60
|
+
if (textarea.current) {
|
|
61
|
+
textarea.current.style.height = "auto";
|
|
62
|
+
textarea.current.style.overflowY = "hidden";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const workspaceItems = [
|
|
68
|
+
...(props.snapshot?.workspaces ?? []).map((workspace) => ({
|
|
69
|
+
id: workspace.path,
|
|
70
|
+
label: workspace.name,
|
|
71
|
+
icon: <Folder />,
|
|
72
|
+
endContent:
|
|
73
|
+
workspace.path === props.selectedWorkspace ? <Check /> : undefined,
|
|
74
|
+
onClick: () => props.actions.setWorkspace(workspace.path),
|
|
75
|
+
})),
|
|
76
|
+
{ type: "divider" as const },
|
|
77
|
+
{
|
|
78
|
+
id: "add",
|
|
79
|
+
label: t("addWorkspaceMenu"),
|
|
80
|
+
icon: <Plus />,
|
|
81
|
+
onClick: () => void props.actions.chooseWorkspace(),
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
const currentModel =
|
|
85
|
+
props.snapshot?.models.find((model) => model.current) ??
|
|
86
|
+
props.snapshot?.models[0];
|
|
87
|
+
const modelItems = (props.snapshot?.models ?? []).map((model) => ({
|
|
88
|
+
id: `${model.provider}/${model.id}`,
|
|
89
|
+
label: model.label,
|
|
90
|
+
endContent: model.current ? <Check /> : undefined,
|
|
91
|
+
onClick: () =>
|
|
92
|
+
void props.actions.selectModel(`${model.provider}/${model.id}`),
|
|
93
|
+
}));
|
|
94
|
+
const placeholder = !props.selectedWorkspace
|
|
95
|
+
? t("promptStart")
|
|
96
|
+
: props.landing
|
|
97
|
+
? t("promptTask")
|
|
98
|
+
: active
|
|
99
|
+
? t("promptMessage")
|
|
100
|
+
: t("promptReadonly");
|
|
101
|
+
const hint = props.turnCancellationPending
|
|
102
|
+
? t("stoppingTurn")
|
|
103
|
+
: props.turnTerminalStatus === "cancelled"
|
|
104
|
+
? t("stoppedTurn")
|
|
105
|
+
: props.pendingFollowUpsReceipt !== null
|
|
106
|
+
? props.pendingFollowUpsReceipt > 0
|
|
107
|
+
? t("pendingFollowUpsHint", { count: props.pendingFollowUpsReceipt })
|
|
108
|
+
: t("acceptedHint")
|
|
109
|
+
: canCompose
|
|
110
|
+
? running
|
|
111
|
+
? t("queuedHint")
|
|
112
|
+
: t("enterHint")
|
|
113
|
+
: t("activeOnlyHint");
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div className="composer-dock">
|
|
117
|
+
{active && <ActivityBar snapshot={props.snapshot} />}
|
|
118
|
+
{props.landing && (
|
|
119
|
+
<div className="workspace-picker-row">
|
|
120
|
+
<DropdownMenu
|
|
121
|
+
className="workspace-picker-menu"
|
|
122
|
+
button={{
|
|
123
|
+
label: props.selectedWorkspace
|
|
124
|
+
? workspaceName(props.selectedWorkspace)
|
|
125
|
+
: t("selectWorkspace"),
|
|
126
|
+
icon: <Folder />,
|
|
127
|
+
size: "md",
|
|
128
|
+
variant: "ghost",
|
|
129
|
+
className: "workspace-picker",
|
|
130
|
+
}}
|
|
131
|
+
items={workspaceItems}
|
|
132
|
+
menuWidth={240}
|
|
133
|
+
placement="above"
|
|
134
|
+
alignment="start"
|
|
135
|
+
hasChevron
|
|
136
|
+
/>
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
<form
|
|
140
|
+
className={`composer ${props.selectedWorkspace ? "" : "dormant"}`}
|
|
141
|
+
onSubmit={(event) => void send(event)}
|
|
142
|
+
>
|
|
143
|
+
{!props.selectedWorkspace && (
|
|
144
|
+
<button
|
|
145
|
+
className="dormant-overlay"
|
|
146
|
+
type="button"
|
|
147
|
+
aria-label={t("selectWorkspace")}
|
|
148
|
+
onClick={() => void props.actions.chooseWorkspace()}
|
|
149
|
+
/>
|
|
150
|
+
)}
|
|
151
|
+
<textarea
|
|
152
|
+
ref={textarea}
|
|
153
|
+
value={prompt}
|
|
154
|
+
rows={1}
|
|
155
|
+
disabled={disabled}
|
|
156
|
+
readOnly={!props.selectedWorkspace}
|
|
157
|
+
aria-label={t("describeTask")}
|
|
158
|
+
placeholder={placeholder}
|
|
159
|
+
onChange={(event) => {
|
|
160
|
+
setPrompt(event.target.value);
|
|
161
|
+
resize(event.currentTarget);
|
|
162
|
+
}}
|
|
163
|
+
onKeyDown={(event) => {
|
|
164
|
+
if (
|
|
165
|
+
event.key === "Enter" &&
|
|
166
|
+
!event.shiftKey &&
|
|
167
|
+
!event.nativeEvent.isComposing
|
|
168
|
+
) {
|
|
169
|
+
event.preventDefault();
|
|
170
|
+
void send();
|
|
171
|
+
}
|
|
172
|
+
}}
|
|
173
|
+
/>
|
|
174
|
+
<div className="composer-toolbar">
|
|
175
|
+
<div className="model-picker-wrap">
|
|
176
|
+
<DropdownMenu
|
|
177
|
+
className="model-menu"
|
|
178
|
+
button={{
|
|
179
|
+
label: currentModel?.label || t("noModels"),
|
|
180
|
+
endContent: <ChevronDown />,
|
|
181
|
+
size: "sm",
|
|
182
|
+
variant: "ghost",
|
|
183
|
+
className: "model-picker",
|
|
184
|
+
isDisabled:
|
|
185
|
+
props.sessionSwitching ||
|
|
186
|
+
!active ||
|
|
187
|
+
!props.selectedWorkspace ||
|
|
188
|
+
props.liveRunning ||
|
|
189
|
+
!modelItems.length,
|
|
190
|
+
}}
|
|
191
|
+
items={modelItems}
|
|
192
|
+
menuWidth={260}
|
|
193
|
+
placement="above"
|
|
194
|
+
alignment="end"
|
|
195
|
+
hasChevron={false}
|
|
196
|
+
/>
|
|
197
|
+
</div>
|
|
198
|
+
{canStop ? (
|
|
199
|
+
<Tooltip content={t("stopTurn")} placement="above">
|
|
200
|
+
<button
|
|
201
|
+
className="send-button"
|
|
202
|
+
type="button"
|
|
203
|
+
aria-label={t("stopTurn")}
|
|
204
|
+
disabled={
|
|
205
|
+
props.turnCancellationPending || props.sessionSwitching
|
|
206
|
+
}
|
|
207
|
+
onClick={() => void props.actions.cancelActiveTurn()}
|
|
208
|
+
>
|
|
209
|
+
<Square />
|
|
210
|
+
</button>
|
|
211
|
+
</Tooltip>
|
|
212
|
+
) : (
|
|
213
|
+
<Tooltip content={t("send")} placement="above">
|
|
214
|
+
<button
|
|
215
|
+
className="send-button"
|
|
216
|
+
type="submit"
|
|
217
|
+
aria-label={t("send")}
|
|
218
|
+
disabled={
|
|
219
|
+
props.sessionSwitching ||
|
|
220
|
+
!canCompose ||
|
|
221
|
+
!props.selectedWorkspace ||
|
|
222
|
+
props.promptAdmissionPending ||
|
|
223
|
+
!prompt.trim()
|
|
224
|
+
}
|
|
225
|
+
>
|
|
226
|
+
<Send />
|
|
227
|
+
</button>
|
|
228
|
+
</Tooltip>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
<div className="composer-hint" aria-live="polite">
|
|
232
|
+
{hint}
|
|
233
|
+
</div>
|
|
234
|
+
</form>
|
|
235
|
+
</div>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { Dialog } from "@astryxdesign/core/Dialog";
|
|
2
|
+
import { DropdownMenu } from "@astryxdesign/core/DropdownMenu";
|
|
3
|
+
import type { DropdownMenuOption } from "@astryxdesign/core/DropdownMenu";
|
|
4
|
+
import { Tooltip } from "@astryxdesign/core/Tooltip";
|
|
5
|
+
import {
|
|
6
|
+
Archive,
|
|
7
|
+
ChevronsLeft,
|
|
8
|
+
MoreHorizontal,
|
|
9
|
+
Plus,
|
|
10
|
+
Search,
|
|
11
|
+
SquarePen,
|
|
12
|
+
Trash2,
|
|
13
|
+
X,
|
|
14
|
+
} from "lucide-react";
|
|
15
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
16
|
+
import { useTranslation } from "react-i18next";
|
|
17
|
+
import type { WebSnapshot } from "../../../../protocol/types.ts";
|
|
18
|
+
import { OpenPiLogo } from "../../components/OpenPiLogo.tsx";
|
|
19
|
+
import { relativeTime, sessionTitle } from "../../lib/format.ts";
|
|
20
|
+
import type { WebStoreActions } from "../../store/web-store.ts";
|
|
21
|
+
|
|
22
|
+
interface SessionSidebarProps {
|
|
23
|
+
snapshot: WebSnapshot | null;
|
|
24
|
+
selectedPath: string | null;
|
|
25
|
+
selectedWorkspace: string | null;
|
|
26
|
+
collapsed: Set<string>;
|
|
27
|
+
query: string;
|
|
28
|
+
searchOpen: boolean;
|
|
29
|
+
mobileOpen: boolean;
|
|
30
|
+
actions: WebStoreActions;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type EditTarget = { kind: "workspace" | "session"; path: string; name: string };
|
|
34
|
+
type DeleteTarget = { path: string; name: string };
|
|
35
|
+
|
|
36
|
+
function ActionMenu({
|
|
37
|
+
items,
|
|
38
|
+
label,
|
|
39
|
+
}: {
|
|
40
|
+
items: DropdownMenuOption[];
|
|
41
|
+
label: string;
|
|
42
|
+
}) {
|
|
43
|
+
return (
|
|
44
|
+
<span className="astryx-menu-trigger">
|
|
45
|
+
<DropdownMenu
|
|
46
|
+
button={{
|
|
47
|
+
label,
|
|
48
|
+
icon: <MoreHorizontal />,
|
|
49
|
+
isIconOnly: true,
|
|
50
|
+
size: "sm",
|
|
51
|
+
variant: "ghost",
|
|
52
|
+
className: "menu-action-button",
|
|
53
|
+
}}
|
|
54
|
+
items={items}
|
|
55
|
+
menuWidth={190}
|
|
56
|
+
placement="below"
|
|
57
|
+
alignment="end"
|
|
58
|
+
hasChevron={false}
|
|
59
|
+
/>
|
|
60
|
+
</span>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function SessionSidebar(props: SessionSidebarProps) {
|
|
65
|
+
const { t } = useTranslation();
|
|
66
|
+
const [editTarget, setEditTarget] = useState<EditTarget | null>(null);
|
|
67
|
+
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | null>(null);
|
|
68
|
+
const [draft, setDraft] = useState("");
|
|
69
|
+
const searchInput = useRef<HTMLInputElement>(null);
|
|
70
|
+
const editInput = useRef<HTMLInputElement>(null);
|
|
71
|
+
const snapshot = props.snapshot;
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (props.searchOpen) searchInput.current?.focus();
|
|
75
|
+
}, [props.searchOpen]);
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!editTarget) return;
|
|
78
|
+
editInput.current?.focus();
|
|
79
|
+
editInput.current?.select();
|
|
80
|
+
}, [editTarget]);
|
|
81
|
+
|
|
82
|
+
const grouped = useMemo(() => {
|
|
83
|
+
const query = props.query.trim().toLowerCase();
|
|
84
|
+
const visible = (workspacePath?: string) =>
|
|
85
|
+
(snapshot?.sessions ?? []).filter(
|
|
86
|
+
(session) =>
|
|
87
|
+
!session.archived &&
|
|
88
|
+
(workspacePath === "__ungrouped__"
|
|
89
|
+
? session.ungrouped
|
|
90
|
+
: session.cwd === workspacePath && !session.ungrouped) &&
|
|
91
|
+
(!query ||
|
|
92
|
+
`${sessionTitle(session, t("untitledSession"))} ${session.cwd}`
|
|
93
|
+
.toLowerCase()
|
|
94
|
+
.includes(query)),
|
|
95
|
+
);
|
|
96
|
+
return [
|
|
97
|
+
...(snapshot?.workspaces ?? []).map((workspace) => ({
|
|
98
|
+
...workspace,
|
|
99
|
+
sessions: visible(workspace.path),
|
|
100
|
+
ungrouped: false,
|
|
101
|
+
})),
|
|
102
|
+
{
|
|
103
|
+
path: "__ungrouped__",
|
|
104
|
+
name: t("ungrouped"),
|
|
105
|
+
current: false,
|
|
106
|
+
sessions: visible("__ungrouped__"),
|
|
107
|
+
ungrouped: true,
|
|
108
|
+
},
|
|
109
|
+
].filter(
|
|
110
|
+
(group) => group.sessions.length > 0 || (!group.ungrouped && !query),
|
|
111
|
+
);
|
|
112
|
+
}, [props.query, snapshot, t]);
|
|
113
|
+
|
|
114
|
+
const openEdit = (target: EditTarget) => {
|
|
115
|
+
setDraft(target.name);
|
|
116
|
+
setEditTarget(target);
|
|
117
|
+
};
|
|
118
|
+
const saveEdit = async () => {
|
|
119
|
+
const name = draft.trim();
|
|
120
|
+
if (!editTarget || !name) return;
|
|
121
|
+
if (editTarget.kind === "workspace")
|
|
122
|
+
await props.actions.renameWorkspace(editTarget.path, name);
|
|
123
|
+
else await props.actions.renameSession(editTarget.path, name);
|
|
124
|
+
setEditTarget(null);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<aside className="session-sidebar" aria-label="Session navigation">
|
|
129
|
+
<div className="sidebar-brand">
|
|
130
|
+
<OpenPiLogo compact />
|
|
131
|
+
<Tooltip content={t("collapseSidebar")} placement="end">
|
|
132
|
+
<button
|
|
133
|
+
className="collapse-button"
|
|
134
|
+
type="button"
|
|
135
|
+
aria-label={t("collapseSidebar")}
|
|
136
|
+
onClick={() =>
|
|
137
|
+
props.mobileOpen
|
|
138
|
+
? props.actions.closeMobileSidebar()
|
|
139
|
+
: props.actions.toggleSidebar(false)
|
|
140
|
+
}
|
|
141
|
+
>
|
|
142
|
+
<ChevronsLeft />
|
|
143
|
+
</button>
|
|
144
|
+
</Tooltip>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<button
|
|
148
|
+
className="new-session-button"
|
|
149
|
+
type="button"
|
|
150
|
+
onClick={() =>
|
|
151
|
+
props.selectedWorkspace
|
|
152
|
+
? void props.actions.createSession(props.selectedWorkspace)
|
|
153
|
+
: void props.actions.chooseWorkspace()
|
|
154
|
+
}
|
|
155
|
+
>
|
|
156
|
+
<SquarePen />
|
|
157
|
+
<span>{t("newSession")}</span>
|
|
158
|
+
</button>
|
|
159
|
+
|
|
160
|
+
<div
|
|
161
|
+
className={`workspace-heading ${props.searchOpen ? "is-searching" : ""}`}
|
|
162
|
+
>
|
|
163
|
+
<span className="workspace-heading-label">{t("workspaces")}</span>
|
|
164
|
+
<div className="session-search">
|
|
165
|
+
<Search />
|
|
166
|
+
<input
|
|
167
|
+
ref={searchInput}
|
|
168
|
+
type="search"
|
|
169
|
+
value={props.query}
|
|
170
|
+
placeholder={t("searchPlaceholder")}
|
|
171
|
+
aria-label={t("searchConversations")}
|
|
172
|
+
onChange={(event) => props.actions.setQuery(event.target.value)}
|
|
173
|
+
/>
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
aria-label={t("closeSearch")}
|
|
177
|
+
onClick={() => props.actions.setSearchOpen(false)}
|
|
178
|
+
>
|
|
179
|
+
<X />
|
|
180
|
+
</button>
|
|
181
|
+
</div>
|
|
182
|
+
<div className="workspace-actions">
|
|
183
|
+
<Tooltip content={t("searchConversations")}>
|
|
184
|
+
<button
|
|
185
|
+
className="icon-button"
|
|
186
|
+
type="button"
|
|
187
|
+
aria-label={t("searchConversations")}
|
|
188
|
+
onClick={() => props.actions.setSearchOpen(true)}
|
|
189
|
+
>
|
|
190
|
+
<Search />
|
|
191
|
+
</button>
|
|
192
|
+
</Tooltip>
|
|
193
|
+
<Tooltip content={t("addWorkspace")}>
|
|
194
|
+
<button
|
|
195
|
+
className="icon-button"
|
|
196
|
+
type="button"
|
|
197
|
+
aria-label={t("addWorkspace")}
|
|
198
|
+
onClick={() => void props.actions.chooseWorkspace()}
|
|
199
|
+
>
|
|
200
|
+
<Plus />
|
|
201
|
+
</button>
|
|
202
|
+
</Tooltip>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
<div className="workspace-tree">
|
|
207
|
+
{grouped.length ? (
|
|
208
|
+
grouped.map((group) => {
|
|
209
|
+
const collapsed = props.collapsed.has(group.path);
|
|
210
|
+
return (
|
|
211
|
+
<section
|
|
212
|
+
className={`workspace-group ${collapsed ? "collapsed" : ""}`}
|
|
213
|
+
key={group.path}
|
|
214
|
+
>
|
|
215
|
+
<div className="workspace-button">
|
|
216
|
+
<button
|
|
217
|
+
className="workspace-label"
|
|
218
|
+
type="button"
|
|
219
|
+
aria-expanded={!collapsed}
|
|
220
|
+
title={
|
|
221
|
+
group.path === "__ungrouped__" ? undefined : group.path
|
|
222
|
+
}
|
|
223
|
+
onClick={() => props.actions.toggleWorkspace(group.path)}
|
|
224
|
+
>
|
|
225
|
+
<span className="workspace-toggle" aria-hidden="true">
|
|
226
|
+
<span className="workspace-chevron">⌄</span>
|
|
227
|
+
</span>
|
|
228
|
+
<strong>{group.name}</strong>
|
|
229
|
+
</button>
|
|
230
|
+
{!group.ungrouped && (
|
|
231
|
+
<span className="workspace-row-actions">
|
|
232
|
+
<ActionMenu
|
|
233
|
+
label="Workspace options"
|
|
234
|
+
items={[
|
|
235
|
+
{
|
|
236
|
+
id: "rename",
|
|
237
|
+
label: t("renameWorkspace"),
|
|
238
|
+
icon: <SquarePen />,
|
|
239
|
+
onClick: () =>
|
|
240
|
+
openEdit({
|
|
241
|
+
kind: "workspace",
|
|
242
|
+
path: group.path,
|
|
243
|
+
name: group.name,
|
|
244
|
+
}),
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
id: "remove",
|
|
248
|
+
label: t("removeWorkspace"),
|
|
249
|
+
icon: <Trash2 />,
|
|
250
|
+
variant: "destructive",
|
|
251
|
+
onClick: () =>
|
|
252
|
+
setDeleteTarget({
|
|
253
|
+
path: group.path,
|
|
254
|
+
name: group.name,
|
|
255
|
+
}),
|
|
256
|
+
},
|
|
257
|
+
]}
|
|
258
|
+
/>
|
|
259
|
+
<Tooltip content={t("newSession")}>
|
|
260
|
+
<button
|
|
261
|
+
className="workspace-action"
|
|
262
|
+
type="button"
|
|
263
|
+
aria-label={`${t("newSession")} ${group.name}`}
|
|
264
|
+
onClick={(event) => {
|
|
265
|
+
event.stopPropagation();
|
|
266
|
+
void props.actions.createSession(group.path);
|
|
267
|
+
}}
|
|
268
|
+
>
|
|
269
|
+
<Plus />
|
|
270
|
+
</button>
|
|
271
|
+
</Tooltip>
|
|
272
|
+
</span>
|
|
273
|
+
)}
|
|
274
|
+
</div>
|
|
275
|
+
<div className="workspace-sessions">
|
|
276
|
+
{group.sessions.length ? (
|
|
277
|
+
group.sessions.map((session) => (
|
|
278
|
+
<div className="session-row" key={session.path}>
|
|
279
|
+
<button
|
|
280
|
+
className={`session ${session.path === props.selectedPath ? "active" : ""}`}
|
|
281
|
+
type="button"
|
|
282
|
+
aria-current={
|
|
283
|
+
session.path === props.selectedPath
|
|
284
|
+
? "page"
|
|
285
|
+
: undefined
|
|
286
|
+
}
|
|
287
|
+
title={sessionTitle(session, t("untitledSession"))}
|
|
288
|
+
onClick={() =>
|
|
289
|
+
void props.actions.selectSession(session.path)
|
|
290
|
+
}
|
|
291
|
+
>
|
|
292
|
+
<span className="session-title">
|
|
293
|
+
{sessionTitle(session, t("untitledSession"))}
|
|
294
|
+
</span>
|
|
295
|
+
<span className="session-time">
|
|
296
|
+
{relativeTime(session.modified)}
|
|
297
|
+
</span>
|
|
298
|
+
</button>
|
|
299
|
+
<ActionMenu
|
|
300
|
+
label={t("conversationOptions")}
|
|
301
|
+
items={[
|
|
302
|
+
{
|
|
303
|
+
id: "rename",
|
|
304
|
+
label: t("renameConversation"),
|
|
305
|
+
icon: <SquarePen />,
|
|
306
|
+
onClick: () =>
|
|
307
|
+
openEdit({
|
|
308
|
+
kind: "session",
|
|
309
|
+
path: session.path,
|
|
310
|
+
name: sessionTitle(
|
|
311
|
+
session,
|
|
312
|
+
t("untitledSession"),
|
|
313
|
+
),
|
|
314
|
+
}),
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
id: "archive",
|
|
318
|
+
label: t("archiveConversation"),
|
|
319
|
+
icon: <Archive />,
|
|
320
|
+
onClick: () =>
|
|
321
|
+
void props.actions.archiveSession(session.path),
|
|
322
|
+
},
|
|
323
|
+
]}
|
|
324
|
+
/>
|
|
325
|
+
</div>
|
|
326
|
+
))
|
|
327
|
+
) : (
|
|
328
|
+
<div className="empty">{t("noConversations")}</div>
|
|
329
|
+
)}
|
|
330
|
+
</div>
|
|
331
|
+
</section>
|
|
332
|
+
);
|
|
333
|
+
})
|
|
334
|
+
) : (
|
|
335
|
+
<div className="empty">
|
|
336
|
+
{props.query ? t("noMatching") : t("noSessions")}
|
|
337
|
+
</div>
|
|
338
|
+
)}
|
|
339
|
+
</div>
|
|
340
|
+
|
|
341
|
+
<Dialog
|
|
342
|
+
isOpen={Boolean(editTarget)}
|
|
343
|
+
onOpenChange={(open: boolean) => !open && setEditTarget(null)}
|
|
344
|
+
purpose="form"
|
|
345
|
+
width={400}
|
|
346
|
+
aria-label={
|
|
347
|
+
editTarget?.kind === "workspace"
|
|
348
|
+
? t("renameWorkspace")
|
|
349
|
+
: t("renameConversation")
|
|
350
|
+
}
|
|
351
|
+
>
|
|
352
|
+
<form
|
|
353
|
+
className="openpi-dialog"
|
|
354
|
+
onSubmit={(event) => {
|
|
355
|
+
event.preventDefault();
|
|
356
|
+
void saveEdit();
|
|
357
|
+
}}
|
|
358
|
+
>
|
|
359
|
+
<strong>
|
|
360
|
+
{editTarget?.kind === "workspace"
|
|
361
|
+
? t("renameWorkspace")
|
|
362
|
+
: t("renameConversation")}
|
|
363
|
+
</strong>
|
|
364
|
+
<input
|
|
365
|
+
ref={editInput}
|
|
366
|
+
value={draft}
|
|
367
|
+
maxLength={80}
|
|
368
|
+
aria-label={
|
|
369
|
+
editTarget?.kind === "workspace"
|
|
370
|
+
? t("workspaceName")
|
|
371
|
+
: t("conversationName")
|
|
372
|
+
}
|
|
373
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
374
|
+
/>
|
|
375
|
+
<div className="dialog-actions">
|
|
376
|
+
<button type="button" onClick={() => setEditTarget(null)}>
|
|
377
|
+
{t("cancel")}
|
|
378
|
+
</button>
|
|
379
|
+
<button type="submit" className="primary">
|
|
380
|
+
{t("save")}
|
|
381
|
+
</button>
|
|
382
|
+
</div>
|
|
383
|
+
</form>
|
|
384
|
+
</Dialog>
|
|
385
|
+
|
|
386
|
+
<Dialog
|
|
387
|
+
isOpen={Boolean(deleteTarget)}
|
|
388
|
+
onOpenChange={(open: boolean) => !open && setDeleteTarget(null)}
|
|
389
|
+
purpose="form"
|
|
390
|
+
width={440}
|
|
391
|
+
aria-label={t("deleteWorkspace")}
|
|
392
|
+
>
|
|
393
|
+
<div className="openpi-dialog">
|
|
394
|
+
<strong>{t("deleteWorkspace")}</strong>
|
|
395
|
+
<p>
|
|
396
|
+
{deleteTarget?.name}:{t("workspaceDeleteConfirm")}
|
|
397
|
+
</p>
|
|
398
|
+
<div className="dialog-actions">
|
|
399
|
+
<button type="button" onClick={() => setDeleteTarget(null)}>
|
|
400
|
+
{t("cancel")}
|
|
401
|
+
</button>
|
|
402
|
+
<button
|
|
403
|
+
type="button"
|
|
404
|
+
className="danger"
|
|
405
|
+
onClick={() => {
|
|
406
|
+
if (!deleteTarget) return;
|
|
407
|
+
void props.actions.removeWorkspace(deleteTarget.path);
|
|
408
|
+
setDeleteTarget(null);
|
|
409
|
+
}}
|
|
410
|
+
>
|
|
411
|
+
{t("deleteWorkspace")}
|
|
412
|
+
</button>
|
|
413
|
+
</div>
|
|
414
|
+
</div>
|
|
415
|
+
</Dialog>
|
|
416
|
+
</aside>
|
|
417
|
+
);
|
|
418
|
+
}
|