Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
863 changes: 862 additions & 1 deletion src-tauri/src/commands/conversations.rs

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions src-tauri/src/db/entities/conversation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,11 @@ pub struct Model {
/// the sidebar's "Pinned" section (sorted by this timestamp descending).
/// Pinning never bumps `updated_at` — it is a view preference, not activity.
pub pinned_at: Option<DateTimeUtc>,
/// The working directory this conversation actually ran in, when that
/// differs from its (current) folder's path — written when a deleted task
/// worktree's conversations are re-parented to the project folder. The
/// Gemini/Cline/OpenClaw stale-external-id fallback matches on
/// `origin_cwd ?? folder.path`. Always NULL for ordinary conversations.
/// The first working directory associated with this conversation's native
/// transcript when it differs from the current folder. Written by an
/// explicit conversation move or when a deleted task worktree is
/// re-parented. The Gemini/Cline/OpenClaw stale-external-id fallback matches
/// on `origin_cwd ?? folder.path`; moving back to the origin clears it.
pub origin_cwd: Option<String>,
}

Expand Down
10 changes: 5 additions & 5 deletions src-tauri/src/db/migration/m20260801_000001_work_task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,11 +216,11 @@ impl MigrationTrait for Migration {
)
.await?;

// conversation.origin_cwd: the working directory a conversation actually
// ran in when that differs from its (current) folder's path — written when
// a deleted task worktree's conversations are re-parented to the project
// folder. The Gemini/Cline/OpenClaw stale-external-id fallback matches on
// `origin_cwd ?? folder.path`. Always NULL for ordinary conversations.
// conversation.origin_cwd: the native transcript's first working
// directory when that differs from the current folder — written by an
// explicit conversation move or a deleted-worktree re-parent. The
// Gemini/Cline/OpenClaw stale-external-id fallback matches on
// `origin_cwd ?? folder.path`; moving back to the origin clears it.
manager
.alter_table(
Table::alter()
Expand Down
19 changes: 19 additions & 0 deletions src-tauri/src/db/service/tab_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,25 @@ fn version_lock() -> &'static Mutex<()> {
LOCK.get_or_init(|| Mutex::new(()))
}

/// Hold the same process-wide guard used by every tab-version mutation while a
/// higher-level operation changes both a conversation and its persisted tab in
/// one database transaction. Keeping this accessor here prevents callers from
/// accidentally introducing a second lock that would not serialize against
/// [`save_all_tabs_cas`].
pub(crate) async fn lock_version_mutations() -> tokio::sync::MutexGuard<'static, ()> {
version_lock().lock().await
}

/// Advance the tab-set clock inside a caller-owned transaction while
/// [`lock_version_mutations`] is held. This is split from the public mutation
/// helpers for compound operations that must commit another table change and
/// the corresponding tab rewrite atomically.
pub(crate) async fn bump_version_locked<C: ConnectionTrait>(conn: &C) -> Result<i64, DbError> {
let next = get_tabs_version(conn).await? + 1;
app_metadata_service::upsert_value(conn, OPENED_TABS_VERSION_KEY, &next.to_string()).await?;
Ok(next)
}

/// Workspace-global logical clock for the open-tab set, stored in the
/// `app_metadata` KV table (survives restart, stays monotonic). Bumped on every
/// accepted mutation; used for compare-and-set (lost-update prevention) and for
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1035,6 +1035,7 @@ mod tauri_app {
conversations::create_chat_dir,
conversations::update_conversation_status,
conversations::update_conversation_title,
conversations::move_conversation,
conversations::update_conversation_pinned,
conversations::delete_conversation,
folders::load_folder_history,
Expand Down
6 changes: 3 additions & 3 deletions src-tauri/src/models/conversation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ pub struct DbConversationSummary {
pub parent_tool_use_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delegation_call_id: Option<String>,
/// Mirror of `conversation.origin_cwd`: the working directory this
/// conversation actually ran in when it differs from its current folder's
/// path (set when a removed task worktree's conversations were re-parented).
/// Mirror of `conversation.origin_cwd`: the native transcript's first
/// working directory when it differs from the conversation's current
/// folder (explicit move or removed-worktree re-parent).
#[serde(skip_serializing_if = "Option::is_none")]
pub origin_cwd: Option<String>,
}
Expand Down
22 changes: 22 additions & 0 deletions src-tauri/src/web/handlers/conversations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,28 @@ pub async fn update_conversation_title(
Ok(Json(()))
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MoveConversationParams {
pub conversation_id: i32,
pub target_folder_id: i32,
}

pub async fn move_conversation(
Extension(state): Extension<Arc<AppState>>,
Json(params): Json<MoveConversationParams>,
) -> Result<Json<DbConversationSummary>, AppCommandError> {
let summary = conv_commands::move_conversation_with_runtime_core(
&state.emitter,
&state.db.conn,
&state.connection_manager,
params.conversation_id,
params.target_folder_id,
)
.await?;
Ok(Json(summary))
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateConversationPinnedParams {
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/web/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,10 @@ pub fn build_router(
"/update_conversation_title",
post(handlers::conversations::update_conversation_title),
)
.route(
"/move_conversation",
post(handlers::conversations::move_conversation),
)
.route(
"/update_conversation_pinned",
post(handlers::conversations::update_conversation_pinned),
Expand Down
42 changes: 42 additions & 0 deletions src-tauri/tests/api_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,48 @@ async fn open_folder_then_list_open_folders_shows_it() {
);
}

#[tokio::test]
async fn move_conversation_route_accepts_camel_case_and_returns_the_new_folder() {
let (server, state, _data, _static) = build_test_server_with_state().await;
let source = tempfile::tempdir().expect("source tempdir");
let target = tempfile::tempdir().expect("target tempdir");
let source_path = source.path().to_string_lossy().to_string();
let source_folder =
codeg_lib::db::service::folder_service::add_folder(&state.db.conn, &source_path)
.await
.expect("source folder");
let target_folder = codeg_lib::db::service::folder_service::add_folder(
&state.db.conn,
&target.path().to_string_lossy(),
)
.await
.expect("target folder");
let conversation = codeg_lib::db::service::conversation_service::create(
&state.db.conn,
source_folder.id,
codeg_lib::models::AgentType::Codex,
Some("move over HTTP".into()),
None,
)
.await
.expect("conversation");

let response = server
.post("/api/move_conversation")
.add_header("authorization", format!("Bearer {TEST_TOKEN}"))
.json(&json!({
"conversationId": conversation.id,
"targetFolderId": target_folder.id,
}))
.await;

assert_eq!(response.status_code(), 200, "body: {}", response.text());
let body: Value = response.json();
assert_eq!(body["id"], conversation.id);
assert_eq!(body["folder_id"], target_folder.id);
assert_eq!(body["origin_cwd"], source_path);
}

#[tokio::test]
async fn acp_find_connection_for_conversation_returns_null_when_none_live() {
// No live ACP connection is bound to any conversation on a fresh server, so
Expand Down
29 changes: 28 additions & 1 deletion src/components/conversations/conversation-detail-header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ vi.mock("@/stores/app-workspace-store", () => {
const state = {
updateConversationLocal: h.updateConversationLocal,
refreshConversations: h.refreshConversations,
conversations: [] as unknown[],
conversations: [
{ id: 1, kind: "regular", parent_id: null, pinned_at: null },
{ id: 2, kind: "regular", parent_id: null, pinned_at: null },
],
}
const useStore = (selector: (s: typeof state) => unknown) => selector(state)
useStore.getState = () => state
Expand All @@ -51,6 +54,17 @@ vi.mock("@/stores/conversation-runtime-store", () => ({
vi.mock("./session-details-dialog", () => ({
SessionDetailsDialog: () => null,
}))
vi.mock("./conversation-move-dialog", () => ({
ConversationMoveDialog: ({
target,
}: {
target: { conversationId: number; folderPath?: string }
}) => (
<output data-testid="move-target">
{target.conversationId}:{target.folderPath}
</output>
),
}))
// The header now embeds the folder picker (self-contained, store-driven); stub
// it so these tests exercise only the header's own menu/dialog logic.
vi.mock("@/components/chat/conversation-context-bar", () => ({
Expand Down Expand Up @@ -135,4 +149,17 @@ describe("ConversationDetailHeader dialog target snapshot", () => {
})
expect(h.updateConversationTitle).not.toHaveBeenCalledWith(2, "renamed")
})

it("keeps the move target snapshotted when the active tab switches", async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 })
const { rerender, getByLabelText, getByRole, getByTestId } = render(
withIntl(<ConversationDetailHeader {...A} />)
)

await user.click(getByLabelText("More actions"))
await user.click(getByRole("menuitem", { name: "Move to folder" }))
rerender(withIntl(<ConversationDetailHeader {...B} />))

expect(getByTestId("move-target")).toHaveTextContent("1:/a")
})
})
37 changes: 37 additions & 0 deletions src/components/conversations/conversation-detail-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ChevronRight,
Circle,
EllipsisVertical,
FolderInput,
Info,
Pencil,
Pin,
Expand Down Expand Up @@ -62,6 +63,10 @@ import {
type ActiveSessionDetails,
} from "./active-session-details"
import { SessionDetailsDialog } from "./session-details-dialog"
import {
ConversationMoveDialog,
type ConversationMoveTarget,
} from "./conversation-move-dialog"

interface ConversationDetailHeaderProps {
tabId: string
Expand Down Expand Up @@ -126,6 +131,11 @@ export const ConversationDetailHeader = memo(function ConversationDetailHeader({
(s.conversations.find((c) => c.id === conversationId)?.pinned_at ??
null) != null
)
const moveEligible = useAppWorkspaceStore((s) => {
if (conversationId == null) return false
const summary = s.conversations.find((c) => c.id === conversationId)
return summary?.kind === "regular" && summary.parent_id == null
})

const [details, setDetails] = useState<ActiveSessionDetails | null>(null)
// Snapshot the action target when a dialog OPENS. The header is a SINGLE
Expand All @@ -143,6 +153,9 @@ export const ConversationDetailHeader = memo(function ConversationDetailHeader({
tabId: string
title: string
} | null>(null)
const [moveTarget, setMoveTarget] = useState<ConversationMoveTarget | null>(
null
)

const persisted = conversationId != null
const displayTitle =
Expand Down Expand Up @@ -205,6 +218,16 @@ export const ConversationDetailHeader = memo(function ConversationDetailHeader({
setDeleteTarget({ id: conversationId, tabId, title: displayTitle })
}, [conversationId, tabId, displayTitle])

const handleMoveOpen = useCallback(() => {
if (conversationId == null || !moveEligible) return
setMoveTarget({
conversationId,
folderId,
folderPath,
title: displayTitle,
})
}, [conversationId, displayTitle, folderId, folderPath, moveEligible])

const handleDeleteConfirm = useCallback(async () => {
if (deleteTarget == null) return
try {
Expand Down Expand Up @@ -299,6 +322,13 @@ export const ConversationDetailHeader = memo(function ConversationDetailHeader({
<Info className="h-4 w-4" />
{tDetails("menuLabel")}
</DropdownMenuItem>
<DropdownMenuItem
disabled={!moveEligible}
onSelect={handleMoveOpen}
>
<FolderInput className="h-4 w-4" />
{t("moveConversation")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={!persisted}>
Expand Down Expand Up @@ -394,6 +424,13 @@ export const ConversationDetailHeader = memo(function ConversationDetailHeader({
model={details.model}
/>
)}

{moveTarget ? (
<ConversationMoveDialog
target={moveTarget}
onClose={() => setMoveTarget(null)}
/>
) : null}
</div>
)
})
Loading
Loading